diff --git a/.gitignore b/.gitignore index 72ed76d..c5d9ebe 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ backend/target/ backend/build/ # === Python (prediction) === +.venv/ prediction/.venv/ prediction/__pycache__/ prediction/**/__pycache__/ diff --git a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java index 2df79d4..8410d99 100644 --- a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java +++ b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java @@ -12,6 +12,7 @@ import org.springframework.context.annotation.Configuration; public class AppProperties { private Prediction prediction = new Prediction(); + private SignalBatch signalBatch = new SignalBatch(); private IranBackend iranBackend = new IranBackend(); private Cors cors = new Cors(); private Jwt jwt = new Jwt(); @@ -21,6 +22,11 @@ public class AppProperties { private String baseUrl; } + @Getter @Setter + public static class SignalBatch { + private String baseUrl; + } + @Getter @Setter public static class IranBackend { private String baseUrl; diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java index 59ab371..34a97ff 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java @@ -1,67 +1,143 @@ package gc.mda.kcg.domain.analysis; +import gc.mda.kcg.config.AppProperties; import gc.mda.kcg.permission.annotation.RequirePermission; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; import java.util.Map; /** - * Prediction (Python FastAPI) 서비스 프록시. - * 현재는 stub - Phase 5에서 실 연결. + * Prediction FastAPI 서비스 프록시. + * app.prediction.base-url (기본: http://localhost:8001, 운영: http://192.168.1.19:18092) + * + * 엔드포인트: + * GET /api/prediction/health → FastAPI /health + * GET /api/prediction/status → FastAPI /api/v1/analysis/status + * POST /api/prediction/trigger → FastAPI /api/v1/analysis/trigger + * POST /api/prediction/chat → stub (Phase 9) + * GET /api/prediction/groups/{key}/history → FastAPI /api/v1/groups/{key}/history?hours= + * GET /api/prediction/correlation/{key}/tracks → FastAPI /api/v1/correlation/{key}/tracks?hours=&min_score= */ +@Slf4j @RestController @RequestMapping("/api/prediction") @RequiredArgsConstructor public class PredictionProxyController { - private final IranBackendClient iranClient; + private final AppProperties appProperties; + private final RestClient.Builder restClientBuilder; + + private RestClient predictionClient; + + @PostConstruct + void init() { + String baseUrl = appProperties.getPrediction().getBaseUrl(); + predictionClient = restClientBuilder + .baseUrl(baseUrl != null && !baseUrl.isBlank() ? baseUrl : "http://localhost:8001") + .defaultHeader("Accept", "application/json") + .build(); + log.info("PredictionProxyController initialized: baseUrl={}", baseUrl); + } @GetMapping("/health") public ResponseEntity health() { - Map data = iranClient.getJson("/api/prediction/health"); - if (data == null) { - return ResponseEntity.ok(Map.of( - "status", "DISCONNECTED", - "message", "Prediction 서비스 미연결 (Phase 5에서 연결 예정)" - )); - } - return ResponseEntity.ok(data); + return proxyGet("/health", Map.of( + "status", "DISCONNECTED", + "message", "Prediction 서비스 미연결" + )); } @GetMapping("/status") @RequirePermission(resource = "monitoring", operation = "READ") public ResponseEntity status() { - Map data = iranClient.getJson("/api/prediction/status"); - if (data == null) { - return ResponseEntity.ok(Map.of("status", "DISCONNECTED")); - } - return ResponseEntity.ok(data); + return proxyGet("/api/v1/analysis/status", Map.of("status", "DISCONNECTED")); } @PostMapping("/trigger") @RequirePermission(resource = "ai-operations:mlops", operation = "UPDATE") public ResponseEntity trigger() { - return ResponseEntity.ok(Map.of("ok", false, "message", "Prediction 서비스 미연결")); + return proxyPost("/api/v1/analysis/trigger", null, + Map.of("ok", false, "message", "Prediction 서비스 미연결")); } /** - * AI 채팅 프록시 (POST). - * 향후 prediction 인증 통과 후 SSE 스트리밍으로 전환. + * AI 채팅 프록시 (POST) — Phase 9에서 실 연결. */ @PostMapping("/chat") @RequirePermission(resource = "ai-operations:ai-assistant", operation = "READ") - public ResponseEntity chat(@org.springframework.web.bind.annotation.RequestBody Map body) { - // iran 백엔드에 인증 토큰이 필요하므로 현재 stub 응답 - // 향후: iranClient에 Bearer 토큰 전달 + SSE 스트리밍 + public ResponseEntity chat(@RequestBody Map body) { return ResponseEntity.ok(Map.of( "ok", false, "serviceAvailable", false, - "message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: " + body.getOrDefault("message", "") + "message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: " + + body.getOrDefault("message", "") )); } + + /** + * 그룹 스냅샷 이력 (FastAPI 위임). + */ + @GetMapping("/groups/{groupKey}/history") + @RequirePermission(resource = "detection:gear-detection", operation = "READ") + public ResponseEntity groupHistory( + @PathVariable String groupKey, + @RequestParam(defaultValue = "24") int hours + ) { + return proxyGet("/api/v1/groups/" + groupKey + "/history?hours=" + hours, + Map.of("serviceAvailable", false, "groupKey", groupKey)); + } + + /** + * 상관관계 궤적 (FastAPI 위임). + */ + @GetMapping("/correlation/{groupKey}/tracks") + @RequirePermission(resource = "detection:gear-detection", operation = "READ") + public ResponseEntity correlationTracks( + @PathVariable String groupKey, + @RequestParam(defaultValue = "24") int hours, + @RequestParam(name = "min_score", required = false) Double minScore + ) { + String path = "/api/v1/correlation/" + groupKey + "/tracks?hours=" + hours; + if (minScore != null) path += "&min_score=" + minScore; + return proxyGet(path, Map.of("serviceAvailable", false, "groupKey", groupKey)); + } + + // ── 내부 헬퍼 ────────────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private ResponseEntity proxyGet(String path, Map fallback) { + try { + Map body = predictionClient.get() + .uri(path) + .retrieve() + .body(Map.class); + return ResponseEntity.ok(body != null ? body : fallback); + } catch (RestClientException e) { + log.debug("Prediction 호출 실패 GET {}: {}", path, e.getMessage()); + return ResponseEntity.ok(fallback); + } + } + + @SuppressWarnings("unchecked") + private ResponseEntity proxyPost(String path, Object requestBody, Map fallback) { + try { + var spec = predictionClient.post().uri(path); + Map body; + if (requestBody != null) { + body = spec.body(requestBody).retrieve().body(Map.class); + } else { + body = spec.retrieve().body(Map.class); + } + return ResponseEntity.ok(body != null ? body : fallback); + } catch (RestClientException e) { + log.debug("Prediction 호출 실패 POST {}: {}", path, e.getMessage()); + return ResponseEntity.ok(fallback); + } + } } diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisGroupService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisGroupService.java new file mode 100644 index 0000000..f2a50f2 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisGroupService.java @@ -0,0 +1,371 @@ +package gc.mda.kcg.domain.analysis; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import gc.mda.kcg.domain.fleet.ParentResolution; +import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 어구 그룹/상관관계 직접 DB 조회 서비스. + * kcg.group_polygon_snapshots, kcg.gear_correlation_scores, + * kcg.correlation_param_models 를 JdbcTemplate으로 직접 쿼리. + * ParentResolution 합성은 JPA를 통해 수행. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class VesselAnalysisGroupService { + + private final JdbcTemplate jdbc; + private final ParentResolutionRepository parentResolutionRepo; + private final ObjectMapper objectMapper; + + /** + * 그룹 목록 (최신 스냅샷 per group_key+sub_cluster_id) + parentResolution 합성. + * 목록에서는 polygon/members를 제외하여 응답 크기를 최소화. + * polygon·members는 detail API에서만 반환. + * + * @param groupType null이면 전체, "GEAR"면 GEAR_IN_ZONE+GEAR_OUT_ZONE만 + */ + public Map getGroups(String groupType) { + List> rows; + + // LATERAL JOIN으로 최신 스냅샷만 빠르게 조회 (DISTINCT ON 대비 60x 개선) + String typeFilter = "GEAR".equals(groupType) + ? "AND group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE')" + : ""; + + String sql = """ + SELECT g.* + FROM ( + SELECT DISTINCT group_key, sub_cluster_id + FROM kcg.group_polygon_snapshots + WHERE snapshot_time > NOW() - INTERVAL '1 hour' + %s + ) keys + JOIN LATERAL ( + SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time, + ST_AsGeoJSON(polygon)::text AS polygon_geojson, + ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, + area_sq_nm, member_count, zone_id, zone_name, members::text, color + FROM kcg.group_polygon_snapshots + WHERE group_key = keys.group_key AND sub_cluster_id = keys.sub_cluster_id + ORDER BY snapshot_time DESC LIMIT 1 + ) g ON true + """.formatted(typeFilter); + + try { + rows = jdbc.queryForList(sql); + } catch (Exception e) { + log.warn("group_polygon_snapshots 조회 실패: {}", e.getMessage()); + return Map.of("serviceAvailable", false, "items", List.of(), "count", 0); + } + + // parentResolution 인덱싱 + Map resolutionByKey = new HashMap<>(); + for (ParentResolution r : parentResolutionRepo.findAll()) { + resolutionByKey.put(r.getGroupKey() + "::" + r.getSubClusterId(), r); + } + + // correlation_scores 실시간 최고 점수 + 후보 수 일괄 조회 + Map corrTopByGroup = new HashMap<>(); + try { + List> corrRows = jdbc.queryForList(""" + SELECT group_key, sub_cluster_id, + MAX(current_score) AS max_score, + COUNT(*) FILTER (WHERE current_score > 0.3) AS candidate_count + FROM kcg.gear_correlation_scores + WHERE model_id = (SELECT id FROM kcg.correlation_param_models WHERE is_default = true LIMIT 1) + AND freeze_state = 'ACTIVE' + GROUP BY group_key, sub_cluster_id + """); + for (Map cr : corrRows) { + String ck = cr.get("group_key") + "::" + cr.get("sub_cluster_id"); + corrTopByGroup.put(ck, new Object[]{cr.get("max_score"), cr.get("candidate_count")}); + } + } catch (Exception e) { + log.debug("correlation top score 조회 실패: {}", e.getMessage()); + } + + List> items = new ArrayList<>(); + for (Map row : rows) { + Map item = buildGroupItem(row); + + String groupKey = String.valueOf(row.get("group_key")); + Object subRaw = row.get("sub_cluster_id"); + Integer sub = subRaw == null ? null : Integer.valueOf(subRaw.toString()); + String compositeKey = groupKey + "::" + sub; + ParentResolution res = resolutionByKey.get(compositeKey); + + // 실시간 최고 점수 (correlation_scores) + Object[] corrTop = corrTopByGroup.get(compositeKey); + if (corrTop != null) { + item.put("liveTopScore", corrTop[0]); + item.put("candidateCount", corrTop[1]); + } + + if (res != null) { + Map resolution = new LinkedHashMap<>(); + resolution.put("status", res.getStatus()); + resolution.put("selectedParentMmsi", res.getSelectedParentMmsi()); + resolution.put("selectedParentName", res.getSelectedParentName()); + resolution.put("topScore", res.getTopScore()); + resolution.put("confidence", res.getConfidence()); + resolution.put("secondScore", res.getSecondScore()); + resolution.put("scoreMargin", res.getScoreMargin()); + resolution.put("decisionSource", res.getDecisionSource()); + resolution.put("stableCycles", res.getStableCycles()); + resolution.put("approvedAt", res.getApprovedAt()); + resolution.put("manualComment", res.getManualComment()); + item.put("resolution", resolution); + } else { + item.put("resolution", null); + } + + items.add(item); + } + + return Map.of("serviceAvailable", true, "count", items.size(), "items", items); + } + + /** + * 단일 그룹 상세 (최신 스냅샷 + 24시간 이력). + */ + public Map getGroupDetail(String groupKey) { + String latestSql = """ + SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time, + ST_AsGeoJSON(polygon)::text AS polygon_geojson, + ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, + area_sq_nm, member_count, zone_id, zone_name, members::text, color + FROM kcg.group_polygon_snapshots + WHERE group_key = ? + ORDER BY snapshot_time DESC + LIMIT 1 + """; + + String historySql = """ + SELECT snapshot_time, + ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, + member_count, + ST_AsGeoJSON(polygon)::text AS polygon_geojson, + members::text, sub_cluster_id, area_sq_nm + FROM kcg.group_polygon_snapshots + WHERE group_key = ? AND snapshot_time > NOW() - INTERVAL '24 hours' + ORDER BY snapshot_time ASC + """; + + try { + List> latestRows = jdbc.queryForList(latestSql, groupKey); + if (latestRows.isEmpty()) { + return Map.of("serviceAvailable", false, "groupKey", groupKey, + "message", "그룹을 찾을 수 없습니다."); + } + + Map latest = buildGroupItem(latestRows.get(0)); + + List> historyRows = jdbc.queryForList(historySql, groupKey); + List> history = new ArrayList<>(); + for (Map row : historyRows) { + Map entry = new LinkedHashMap<>(); + entry.put("snapshotTime", row.get("snapshot_time")); + entry.put("centerLat", row.get("center_lat")); + entry.put("centerLon", row.get("center_lon")); + entry.put("memberCount", row.get("member_count")); + entry.put("polygon", parseGeoJson((String) row.get("polygon_geojson"))); + entry.put("members", parseJsonArray((String) row.get("members"))); + entry.put("subClusterId", row.get("sub_cluster_id")); + entry.put("areaSqNm", row.get("area_sq_nm")); + history.add(entry); + } + + Map result = new LinkedHashMap<>(); + result.put("groupKey", groupKey); + result.put("latest", latest); + result.put("history", history); + return result; + + } catch (Exception e) { + log.warn("getGroupDetail 조회 실패 groupKey={}: {}", groupKey, e.getMessage()); + return Map.of("serviceAvailable", false, "groupKey", groupKey); + } + } + + /** + * 그룹별 상관관계 점수 목록 (활성 모델 기준). + */ + public Map getGroupCorrelations(String groupKey, Double minScore) { + String sql = """ + SELECT s.target_mmsi, s.target_type, s.target_name, + s.current_score AS score, s.streak_count AS streak, + s.observation_count AS observations, s.freeze_state, + 0 AS shadow_bonus, s.sub_cluster_id, + s.proximity_ratio, s.visit_score, s.heading_coherence, + m.id AS model_id, m.name AS model_name, m.is_default + FROM kcg.gear_correlation_scores s + JOIN kcg.correlation_param_models m ON s.model_id = m.id + WHERE s.group_key = ? AND m.is_active = true + AND (? IS NULL OR s.current_score >= ?) + ORDER BY s.current_score DESC + """; + + try { + List> rows = jdbc.queryForList( + sql, groupKey, minScore, minScore); + + List> items = new ArrayList<>(); + for (Map row : rows) { + Map item = new LinkedHashMap<>(); + item.put("targetMmsi", row.get("target_mmsi")); + item.put("targetType", row.get("target_type")); + item.put("targetName", row.get("target_name")); + item.put("score", row.get("score")); + item.put("streak", row.get("streak")); + item.put("observations", row.get("observations")); + item.put("freezeState", row.get("freeze_state")); + item.put("subClusterId", row.get("sub_cluster_id")); + item.put("proximityRatio", row.get("proximity_ratio")); + item.put("visitScore", row.get("visit_score")); + item.put("headingCoherence", row.get("heading_coherence")); + item.put("modelId", row.get("model_id")); + item.put("modelName", row.get("model_name")); + item.put("isDefault", row.get("is_default")); + items.add(item); + } + + return Map.of("groupKey", groupKey, "count", items.size(), "items", items); + + } catch (Exception e) { + log.warn("getGroupCorrelations 조회 실패 groupKey={}: {}", groupKey, e.getMessage()); + return Map.of("serviceAvailable", false, "groupKey", groupKey, "items", List.of(), "count", 0); + } + } + + /** + * 후보 상세 raw metrics (최근 N건). + */ + public Map getCandidateMetrics(String groupKey, String targetMmsi) { + String sql = """ + SELECT observed_at, proximity_ratio, visit_score, activity_sync, + dtw_similarity, speed_correlation, heading_coherence, drift_similarity, + shadow_stay, shadow_return, gear_group_active_ratio + FROM kcg.gear_correlation_raw_metrics + WHERE group_key = ? AND target_mmsi = ? + ORDER BY observed_at DESC LIMIT 20 + """; + try { + List> rows = jdbc.queryForList(sql, groupKey, targetMmsi); + List> items = new ArrayList<>(); + for (Map row : rows) { + Map item = new LinkedHashMap<>(); + item.put("observedAt", row.get("observed_at")); + item.put("proximityRatio", row.get("proximity_ratio")); + item.put("visitScore", row.get("visit_score")); + item.put("activitySync", row.get("activity_sync")); + item.put("dtwSimilarity", row.get("dtw_similarity")); + item.put("speedCorrelation", row.get("speed_correlation")); + item.put("headingCoherence", row.get("heading_coherence")); + item.put("driftSimilarity", row.get("drift_similarity")); + item.put("shadowStay", row.get("shadow_stay")); + item.put("shadowReturn", row.get("shadow_return")); + item.put("gearGroupActiveRatio", row.get("gear_group_active_ratio")); + items.add(item); + } + return Map.of("groupKey", groupKey, "targetMmsi", targetMmsi, "count", items.size(), "items", items); + } catch (Exception e) { + log.warn("getCandidateMetrics 실패: {}", e.getMessage()); + return Map.of("groupKey", groupKey, "targetMmsi", targetMmsi, "items", List.of(), "count", 0); + } + } + + /** + * 모선 확정/제외 처리. + */ + public Map resolveParent(String groupKey, String action, String targetMmsi, String comment) { + try { + // 먼저 resolution 존재 확인 + List> existing = jdbc.queryForList( + "SELECT id, sub_cluster_id FROM kcg.gear_group_parent_resolution WHERE group_key = ? LIMIT 1", + groupKey); + if (existing.isEmpty()) { + return Map.of("ok", false, "message", "resolution을 찾을 수 없습니다."); + } + + Long id = ((Number) existing.get(0).get("id")).longValue(); + + if ("confirm".equals(action)) { + jdbc.update(""" + UPDATE kcg.gear_group_parent_resolution + SET status = 'MANUAL_CONFIRMED', selected_parent_mmsi = ?, + manual_comment = ?, approved_at = NOW(), updated_at = NOW() + WHERE id = ? + """, targetMmsi, comment, id); + } else if ("reject".equals(action)) { + jdbc.update(""" + UPDATE kcg.gear_group_parent_resolution + SET rejected_candidate_mmsi = ?, manual_comment = ?, + rejected_at = NOW(), updated_at = NOW() + WHERE id = ? + """, targetMmsi, comment, id); + } else { + return Map.of("ok", false, "message", "알 수 없는 액션: " + action); + } + return Map.of("ok", true, "action", action, "groupKey", groupKey, "targetMmsi", targetMmsi); + } catch (Exception e) { + log.warn("resolveParent 실패: {}", e.getMessage()); + return Map.of("ok", false, "message", e.getMessage()); + } + } + + // ── 내부 헬퍼 ────────────────────────────────────────────────────────────── + + private Map buildGroupItem(Map row) { + Map item = new LinkedHashMap<>(); + item.put("groupType", row.get("group_type")); + item.put("groupKey", row.get("group_key")); + item.put("groupLabel", row.get("group_label")); + item.put("subClusterId", row.get("sub_cluster_id")); + item.put("snapshotTime", row.get("snapshot_time")); + item.put("polygon", parseGeoJson((String) row.get("polygon_geojson"))); + item.put("centerLat", row.get("center_lat")); + item.put("centerLon", row.get("center_lon")); + item.put("areaSqNm", row.get("area_sq_nm")); + item.put("memberCount", row.get("member_count")); + item.put("zoneId", row.get("zone_id")); + item.put("zoneName", row.get("zone_name")); + item.put("members", parseJsonArray((String) row.get("members"))); + item.put("color", row.get("color")); + item.put("resolution", null); + item.put("candidateCount", null); + return item; + } + + private Map parseGeoJson(String geoJson) { + if (geoJson == null || geoJson.isBlank()) return null; + try { + return objectMapper.readValue(geoJson, new TypeReference>() {}); + } catch (Exception e) { + log.debug("GeoJSON 파싱 실패: {}", e.getMessage()); + return null; + } + } + + private List parseJsonArray(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.debug("JSON 배열 파싱 실패: {}", e.getMessage()); + return List.of(); + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java index 6b55341..4c00d34 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java @@ -1,109 +1,85 @@ package gc.mda.kcg.domain.analysis; -import gc.mda.kcg.domain.fleet.ParentResolution; -import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository; +import gc.mda.kcg.config.AppProperties; import gc.mda.kcg.permission.annotation.RequirePermission; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; -import java.util.*; +import java.util.List; +import java.util.Map; /** - * iran 백엔드 분석 데이터 프록시 + 자체 DB 운영자 결정 합성 (HYBRID). + * 분석 데이터 API — group_polygon_snapshots / gear_correlation_scores 직접 DB 조회 + * + signal-batch 선박 항적 프록시. + */ + +/** + * 분석 데이터 API — group_polygon_snapshots / gear_correlation_scores 직접 DB 조회. * * 라우팅: - * GET /api/vessel-analysis → 전체 분석결과 + 통계 (단순 프록시) - * GET /api/vessel-analysis/groups → 어구/선단 그룹 + parentResolution 합성 - * GET /api/vessel-analysis/groups/{key}/detail → 단일 그룹 상세 + * GET /api/vessel-analysis/groups → 어구/선단 그룹 + parentResolution 합성 + * GET /api/vessel-analysis/groups/{key}/detail → 단일 그룹 상세 + 24h 이력 * GET /api/vessel-analysis/groups/{key}/correlations → 상관관계 점수 * - * 권한: detection / detection:gear-detection (READ) + * 권한: detection:gear-detection (READ) */ +@Slf4j @RestController @RequestMapping("/api/vessel-analysis") @RequiredArgsConstructor public class VesselAnalysisProxyController { - private final IranBackendClient iranClient; - private final ParentResolutionRepository resolutionRepository; + private final VesselAnalysisGroupService groupService; + private final AppProperties appProperties; + private final RestClient.Builder restClientBuilder; + + private RestClient signalBatchClient; + + @PostConstruct + void init() { + String sbUrl = appProperties.getSignalBatch().getBaseUrl(); + signalBatchClient = restClientBuilder + .baseUrl(sbUrl != null && !sbUrl.isBlank() ? sbUrl : "http://192.168.1.18:18090/signal-batch") + .defaultHeader("Accept", "application/json") + .build(); + } @GetMapping @RequirePermission(resource = "detection:dark-vessel", operation = "READ") public ResponseEntity getVesselAnalysis() { - Map data = iranClient.getJson("/api/vessel-analysis"); - if (data == null) { - return ResponseEntity.ok(Map.of( - "serviceAvailable", false, - "message", "iran 백엔드 미연결", - "items", List.of(), - "stats", Map.of(), - "count", 0 - )); - } - // 통과 + 메타데이터 추가 - Map enriched = new LinkedHashMap<>(data); - enriched.put("serviceAvailable", true); - return ResponseEntity.ok(enriched); + // vessel_analysis_results 직접 조회는 /api/analysis/vessels 를 사용. + // 이 엔드포인트는 하위 호환을 위해 빈 구조 반환. + return ResponseEntity.ok(Map.of( + "serviceAvailable", true, + "message", "vessel_analysis_results는 /api/analysis/vessels 에서 제공됩니다.", + "items", List.of(), + "stats", Map.of(), + "count", 0 + )); } /** * 그룹 목록 + 자체 DB의 parentResolution 합성. - * 각 그룹에 resolution 필드 추가. */ @GetMapping("/groups") @RequirePermission(resource = "detection:gear-detection", operation = "READ") - public ResponseEntity getGroups() { - Map data = iranClient.getJson("/api/vessel-analysis/groups"); - if (data == null) { - return ResponseEntity.ok(Map.of( - "serviceAvailable", false, - "items", List.of(), - "count", 0 - )); - } - - @SuppressWarnings("unchecked") - List> items = (List>) data.getOrDefault("items", List.of()); - - // 자체 DB의 모든 resolution을 group_key로 인덱싱 - Map resolutionByKey = new HashMap<>(); - for (ParentResolution r : resolutionRepository.findAll()) { - resolutionByKey.put(r.getGroupKey() + "::" + r.getSubClusterId(), r); - } - - // 각 그룹에 합성 - for (Map item : items) { - String groupKey = String.valueOf(item.get("groupKey")); - Object subRaw = item.get("subClusterId"); - Integer sub = subRaw == null ? null : Integer.valueOf(subRaw.toString()); - ParentResolution res = resolutionByKey.get(groupKey + "::" + sub); - if (res != null) { - Map resolution = new LinkedHashMap<>(); - resolution.put("status", res.getStatus()); - resolution.put("selectedParentMmsi", res.getSelectedParentMmsi()); - resolution.put("approvedAt", res.getApprovedAt()); - resolution.put("manualComment", res.getManualComment()); - item.put("resolution", resolution); - } else { - item.put("resolution", null); - } - } - - Map result = new LinkedHashMap<>(data); - result.put("items", items); - result.put("serviceAvailable", true); + public ResponseEntity getGroups( + @org.springframework.web.bind.annotation.RequestParam(required = false) String groupType + ) { + Map result = groupService.getGroups(groupType); return ResponseEntity.ok(result); } @GetMapping("/groups/{groupKey}/detail") @RequirePermission(resource = "detection:gear-detection", operation = "READ") public ResponseEntity getGroupDetail(@PathVariable String groupKey) { - Map data = iranClient.getJson("/api/vessel-analysis/groups/" + groupKey + "/detail"); - if (data == null) { - return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey)); - } - return ResponseEntity.ok(data); + Map result = groupService.getGroupDetail(groupKey); + return ResponseEntity.ok(result); } @GetMapping("/groups/{groupKey}/correlations") @@ -112,12 +88,57 @@ public class VesselAnalysisProxyController { @PathVariable String groupKey, @RequestParam(required = false) Double minScore ) { - String path = "/api/vessel-analysis/groups/" + groupKey + "/correlations"; - if (minScore != null) path += "?minScore=" + minScore; - Map data = iranClient.getJson(path); - if (data == null) { - return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey)); + Map result = groupService.getGroupCorrelations(groupKey, minScore); + return ResponseEntity.ok(result); + } + + /** + * 후보 상세 raw metrics (최근 20건 관측 이력). + */ + @GetMapping("/groups/{groupKey}/candidates/{targetMmsi}/metrics") + @RequirePermission(resource = "detection:gear-detection", operation = "READ") + public ResponseEntity getCandidateMetrics( + @PathVariable String groupKey, + @PathVariable String targetMmsi + ) { + return ResponseEntity.ok(groupService.getCandidateMetrics(groupKey, targetMmsi)); + } + + /** + * 모선 확정/제외. + * POST body: { "action": "confirm"|"reject", "targetMmsi": "...", "comment": "..." } + */ + @PostMapping("/groups/{groupKey}/resolve") + @RequirePermission(resource = "detection:gear-detection", operation = "UPDATE") + public ResponseEntity resolveParent( + @PathVariable String groupKey, + @RequestBody Map body + ) { + String action = body.getOrDefault("action", ""); + String targetMmsi = body.getOrDefault("targetMmsi", ""); + String comment = body.getOrDefault("comment", ""); + return ResponseEntity.ok(groupService.resolveParent(groupKey, action, targetMmsi, comment)); + } + + /** + * 선박 항적 일괄 조회 (signal-batch 프록시). + * POST /api/vessel-analysis/tracks → signal-batch /api/v2/tracks/vessels + */ + @PostMapping("/tracks") + @RequirePermission(resource = "detection:gear-detection", operation = "READ") + public ResponseEntity vesselTracks(@RequestBody Map body) { + try { + String json = signalBatchClient.post() + .uri("/api/v2/tracks/vessels") + .body(body) + .retrieve() + .body(String.class); + return ResponseEntity.ok() + .header("Content-Type", "application/json") + .body(json != null ? json : "[]"); + } catch (RestClientException e) { + log.warn("signal-batch 항적 조회 실패: {}", e.getMessage()); + return ResponseEntity.ok("[]"); } - return ResponseEntity.ok(data); } } diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentResolution.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentResolution.java index 968d1d7..5396b81 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentResolution.java +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentResolution.java @@ -34,6 +34,27 @@ public class ParentResolution { @Column(name = "selected_parent_mmsi", length = 20) private String selectedParentMmsi; + @Column(name = "selected_parent_name", length = 200) + private String selectedParentName; + + @Column(name = "confidence", columnDefinition = "numeric(7,4)") + private java.math.BigDecimal confidence; + + @Column(name = "top_score", columnDefinition = "numeric(7,4)") + private java.math.BigDecimal topScore; + + @Column(name = "second_score", columnDefinition = "numeric(7,4)") + private java.math.BigDecimal secondScore; + + @Column(name = "score_margin", columnDefinition = "numeric(7,4)") + private java.math.BigDecimal scoreMargin; + + @Column(name = "decision_source", length = 30) + private String decisionSource; + + @Column(name = "stable_cycles", columnDefinition = "integer default 0") + private Integer stableCycles; + @Column(name = "rejected_candidate_mmsi", length = 20) private String rejectedCandidateMmsi; diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 4951daa..ba553f7 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -40,6 +40,10 @@ spring: server: port: 8080 forward-headers-strategy: framework + compression: + enabled: true + min-response-size: 1024 + mime-types: application/json,application/xml,text/html,text/plain management: endpoints: @@ -60,6 +64,8 @@ logging: app: prediction: base-url: ${PREDICTION_BASE_URL:http://localhost:8001} + signal-batch: + base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch} iran-backend: # 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합) base-url: ${IRAN_BACKEND_BASE_URL:https://kcg.gc-si.dev} diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index c4e08dc..974e4f5 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,26 @@ ## [Unreleased] +### 추가 +- **DAR-03 G-code 위반 분류** — prediction에 G-01(수역×어구 위반)/G-04(MMSI 사이클링)/G-05(고정어구 표류)/G-06(쌍끌이 공조) 4개 위반 유형 자동 분류 + 점수 합산 +- **쌍끌이 공조 탐지 알고리즘** — pair_trawl.py 신규 (cell-key 파티션 O(n) 스캔, 500m 근접·0.5kn 속도차·10° COG 일치·2h 지속 임계값) +- **모선 검토 워크플로우** — 어구 판정 상세 패널에 후보 검토 UI 추가 (관측 지표 7종 평균 + 보정 지표 + 모선 확정/제외 버튼). 별도 화면 진입 없이 어구 탐지 페이지 내에서 의사결정 +- **24시간 궤적 리플레이** — TripsLayer fade trail 애니메이션, 멤버별 개별 타임라인 보간(빈 구간 자연 연속), convex hull 폴리곤 실시간 생성, 후보 선박 항적 동시 재생 (signal-batch /api/v2/tracks/vessels 프록시 연동) +- **어구 탐지 그리드 UX** — 다중 선택 필터 패널(설치 해역/판정/위험도/모선 상태/허가/멤버 수 슬라이더, localStorage 영속화), 행 클릭 시 지도 flyTo, 후보 일치율 칼럼 + 정렬 + +### 변경 +- **그리드 후보 일치율 정확도** — resolution.top_score(평가 시점 고정) 대신 correlation_scores.current_score(실시간 갱신)의 최댓값 사용 → 최신 점수 반영 +- **어구 그룹 칼럼 표시** — 모선 후보 MMSI가 그룹명 자리에 표시되던 버그 수정 (groupLabel/groupKey 우선 표시) +- **ParentResolution Entity 확장** — top_score/confidence/score_margin/decision_source/stable_cycles 등 점수 근거 7개 필드 추가 +- **백엔드 correlation API 응답 정규화** — snake_case 컬럼을 camelCase로 명시 매핑 (프론트 매핑 누락 방지) + +### 수정 +- **궤적 리플레이 깜박임** — useMapLayers와 useGearReplayLayers가 동시에 overlay.setProps()를 호출하던 경쟁 조건 제거. 리플레이 활성 시 useMapLayers 비활성화 (단일 렌더링 경로) +- **멤버-중심 연결선 제거** — 어구 그룹 선택 모드에서 불필요하게 그려지던 dashed 연결선 코드 삭제 + +### 기타 +- **루트 .venv/ gitignore 추가** + ## [2026-04-14] ### 추가 diff --git a/frontend/src/features/detection/GearDetection.tsx b/frontend/src/features/detection/GearDetection.tsx index 6ae4430..7c3a32c 100644 --- a/frontend/src/features/detection/GearDetection.tsx +++ b/frontend/src/features/detection/GearDetection.tsx @@ -4,20 +4,61 @@ import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; -import { Anchor, AlertTriangle, Loader2 } from 'lucide-react'; -import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; -import type { MarkerData } from '@lib/map'; +import { Anchor, AlertTriangle, Loader2, Filter, X } from 'lucide-react'; +import type { MapboxOverlay } from '@deck.gl/mapbox'; +import { + BaseMap, createStaticLayers, + createGeoJsonLayer, createGearPolygonLayer, + createShipIconLayer, createGearIconLayer, + type MapHandle, + type ShipIconData, type GearIconData, +} from '@lib/map'; import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi'; import { formatDate } from '@shared/utils/dateFormat'; import { getPermitStatusIntent, getPermitStatusLabel, getGearJudgmentIntent } from '@shared/constants/permissionStatuses'; import { getAlertLevelHex } from '@shared/constants/alertLevels'; import { useSettingsStore } from '@stores/settingsStore'; +import { getZoneCodeIntent, getZoneCodeLabel, getZoneAllowedGears } from '@shared/constants/zoneCodes'; +import { getGearViolationIntent } from '@shared/constants/gearViolationCodes'; +import { getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes'; +import { GearDetailPanel } from './components/GearDetailPanel'; +import { GearReplayController } from './components/GearReplayController'; +import { useGearReplayStore } from '@stores/gearReplayStore'; +import { useGearReplayLayers } from '@/hooks/useGearReplayLayers'; +import fishingZonesGeoJson from '@lib/map/data/fishing-zones-wgs84.json'; /* SFR-10: 불법 어망·어구 탐지 및 관리 */ -type Gear = { id: string; type: string; owner: string; zone: string; status: string; permit: string; installed: string; lastSignal: string; risk: string; lat: number; lng: number; parentStatus: string; parentMmsi: string; confidence: string; [key: string]: unknown; }; +type Gear = { + id: string; + groupKey: string; + type: string; // 어구 그룹 유형 (구역 내/외) + owner: string; // 모선 MMSI 또는 그룹 라벨 + zone: string; // 수역 코드 + status: string; + permit: string; + installed: string; + lastSignal: string; + risk: string; + lat: number; + lng: number; + parentStatus: string; + parentMmsi: string; + confidence: string; + memberCount: number; + members: Array<{ mmsi: string; name?: string; lat?: number; lon?: number; role?: string; isParent?: boolean }>; + // 폴리곤 원본 + polygon: unknown; + // G코드 위반 정보 + gCodes: string[]; + gearViolationScore: number; + gearViolationEvidence: Record>; + pairTrawlDetected: boolean; + pairTrawlPairMmsi: string; + allowedGears: string[]; + topScore: number; // 최대 후보 일치율 (0~1) +}; -// 한글 위험도 → AlertLevel hex 매핑 const RISK_HEX: Record = { '고위험': getAlertLevelHex('CRITICAL'), '중위험': getAlertLevelHex('MEDIUM'), @@ -37,14 +78,31 @@ function deriveStatus(g: GearGroupItem): string { return '확인 중'; } -function mapGroupToGear(g: GearGroupItem, idx: number): Gear { +/** 그룹 유형에서 수역 코드 추론 (backend가 zoneCode를 미제공하므로 groupType 기반) */ +function deriveZone(g: GearGroupItem): string { + if (g.groupType === 'GEAR_OUT_ZONE') return 'EEZ_OR_BEYOND'; + // GEAR_IN_ZONE: 위치 기반 추론 — 위도/경도로 대략적 수역 판별 + const lat = g.centerLat; + const lon = g.centerLon; + if (lat > 37.0 && lon > 129.0) return 'ZONE_I'; // 동해 + if (lat < 34.0 && lon > 127.0) return 'ZONE_II'; // 남해 + if (lat < 35.5 && lon < 127.0) return 'ZONE_III'; // 서남해 + if (lat >= 35.5 && lon < 126.5) return 'ZONE_IV'; // 서해 + return 'ZONE_III'; // 기본값 +} + +function mapGroupToGear(g: GearGroupItem, idx: number, t: (k: string, opts?: { defaultValue?: string }) => string, lang: 'ko' | 'en'): Gear { const risk = deriveRisk(g); const status = deriveStatus(g); + const zone = deriveZone(g); + // 그룹명: 항상 groupLabel/groupKey 사용 (모선 후보 MMSI는 별도 칼럼) + const owner = g.groupLabel || g.groupKey; return { id: `G-${String(idx + 1).padStart(3, '0')}`, - type: g.groupLabel || (g.groupType === 'GEAR_IN_ZONE' ? '지정해역 어구' : '지정해역 외 어구'), - owner: g.members[0]?.name || g.members[0]?.mmsi || '-', - zone: g.groupType === 'GEAR_IN_ZONE' ? '지정해역' : '지정해역 외', + groupKey: g.groupKey, + type: getGearGroupTypeLabel(g.groupType, t, lang), + owner, + zone, status, permit: 'NONE', installed: formatDate(g.snapshotTime), @@ -54,10 +112,54 @@ function mapGroupToGear(g: GearGroupItem, idx: number): Gear { lng: g.centerLon, parentStatus: g.resolution?.status ?? '-', parentMmsi: g.resolution?.selectedParentMmsi ?? '-', - confidence: g.candidateCount != null ? `${g.candidateCount}건` : '-', + confidence: (g.candidateCount ?? 0) > 0 ? `${g.candidateCount}건` : '-', + memberCount: g.memberCount ?? 0, + members: g.members ?? [], + polygon: g.polygon, + gCodes: [], + gearViolationScore: 0, + gearViolationEvidence: {}, + pairTrawlDetected: false, + pairTrawlPairMmsi: '', + allowedGears: getZoneAllowedGears(zone), + topScore: Math.max(g.liveTopScore ?? 0, g.resolution?.topScore ?? 0), }; } +/** 필터 그룹 내 체크박스 목록 */ +function FilterCheckGroup({ label, selected, onChange, options }: { + label: string; + selected: Set; + onChange: (v: Set) => void; + options: { value: string; label: string }[]; +}) { + const toggle = (v: string) => { + const next = new Set(selected); + if (next.has(v)) next.delete(v); else next.add(v); + onChange(next); + }; + return ( +
+
{label} {selected.size > 0 && ({selected.size})}
+
+ {options.map(o => ( + + ))} +
+
+ ); +} + +function ReplayOverlay() { + const groupKey = useGearReplayStore(s => s.groupKey); + if (!groupKey) return null; + return useGearReplayStore.getState().reset()} />; +} + export function GearDetection() { const { t } = useTranslation('detection'); const { t: tc } = useTranslation('common'); @@ -65,43 +167,104 @@ export function GearDetection() { const cols: DataColumn[] = useMemo(() => [ { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, - { key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => {v as string} }, - { key: 'owner', label: '소유 선박', sortable: true, render: v => {v as string} }, - { key: 'zone', label: '설치 해역', width: '90px', sortable: true }, - { key: 'permit', label: '허가 상태', width: '80px', align: 'center', + { key: 'type', label: '그룹 유형', width: '100px', sortable: true, render: v => {v as string} }, + { key: 'owner', label: '어구 그룹', sortable: true, + render: v => {v as string} }, + { key: 'memberCount', label: '멤버', width: '50px', align: 'center', + render: v => {v as number}척 }, + { key: 'zone', label: '설치 해역', width: '130px', sortable: true, + render: (v: unknown) => ( + + {getZoneCodeLabel(v as string, t, lang)} + + ) }, + { key: 'permit', label: '허가', width: '70px', align: 'center', render: v => {getPermitStatusLabel(v as string, tc, lang)} }, { key: 'status', label: '판정', width: '80px', align: 'center', sortable: true, render: v => {v as string} }, - { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, + { key: 'gCodes', label: 'G코드', width: '100px', + render: (_: unknown, row: Gear) => row.gCodes.length > 0 ? ( +
+ {row.gCodes.map(code => ( + {code} + ))} +
+ ) : - }, + { key: 'risk', label: '위험도', width: '65px', align: 'center', sortable: true, render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return {r}; } }, - { key: 'parentStatus', label: '모선 상태', width: '100px', sortable: true, + { key: 'parentStatus', label: '모선 상태', width: '90px', sortable: true, render: v => { const s = v as string; - const intent = s === 'DIRECT_PARENT_MATCH' ? 'success' : s === 'AUTO_PROMOTED' ? 'info' : s === 'REVIEW_REQUIRED' ? 'warning' : s === 'UNRESOLVED' ? 'muted' : 'muted'; + const intent = s === 'DIRECT_PARENT_MATCH' ? 'success' : s === 'AUTO_PROMOTED' ? 'info' : s === 'REVIEW_REQUIRED' ? 'warning' : 'muted'; const label = s === 'DIRECT_PARENT_MATCH' ? '직접매칭' : s === 'AUTO_PROMOTED' ? '자동승격' : s === 'REVIEW_REQUIRED' ? '심사필요' : s === 'UNRESOLVED' ? '미결정' : s; return {label}; } }, { key: 'parentMmsi', label: '추정 모선', width: '100px', render: v => { const m = v as string; return m !== '-' ? {m} : -; } }, - { key: 'confidence', label: '후보', width: '50px', align: 'center', - render: v => {v as string} }, + { key: 'topScore', label: '후보 일치', width: '75px', align: 'center', sortable: true, + render: (v: unknown) => { + const s = v as number; + if (s <= 0) return -; + const pct = Math.round(s * 100); + const c = pct >= 72 ? 'text-green-400' : pct >= 50 ? 'text-yellow-400' : 'text-hint'; + return {pct}%; + } }, { key: 'lastSignal', label: '최종 신호', width: '80px', render: v => {v as string} }, - ], [tc, lang]); + ], [t, tc, lang]); const [groups, setGroups] = useState([]); const [serviceAvailable, setServiceAvailable] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [selectedId, setSelectedId] = useState(null); + + // ── 필터 상태 (다중 선택, localStorage 영속화) ── + const [filterOpen, setFilterOpen] = useState(() => { + try { return JSON.parse(localStorage.getItem('kcg-gear-filter-open') ?? 'false'); } catch { return false; } + }); + const [filterZone, setFilterZone] = useState>(() => { + try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fz') ?? '[]')); } catch { return new Set(); } + }); + const [filterStatus, setFilterStatus] = useState>(() => { + try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fs') ?? '[]')); } catch { return new Set(); } + }); + const [filterRisk, setFilterRisk] = useState>(() => { + try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fr') ?? '[]')); } catch { return new Set(); } + }); + const [filterParentStatus, setFilterParentStatus] = useState>(() => { + try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fps') ?? '[]')); } catch { return new Set(); } + }); + const [filterPermit, setFilterPermit] = useState>(() => { + try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fp') ?? '[]')); } catch { return new Set(); } + }); + const [filterMemberMin, setFilterMemberMin] = useState(() => { + try { return JSON.parse(localStorage.getItem('kcg-gear-fmn') ?? '2'); } catch { return 2; } + }); + const [filterMemberMax, setFilterMemberMax] = useState(() => { + try { const v = localStorage.getItem('kcg-gear-fmx'); return v ? JSON.parse(v) : Infinity; } catch { return Infinity; } + }); + const checkFilterCount = filterZone.size + filterStatus.size + filterRisk.size + filterParentStatus.size + filterPermit.size; + + // localStorage 동기화 + useEffect(() => { + localStorage.setItem('kcg-gear-filter-open', JSON.stringify(filterOpen)); + localStorage.setItem('kcg-gear-fz', JSON.stringify([...filterZone])); + localStorage.setItem('kcg-gear-fs', JSON.stringify([...filterStatus])); + localStorage.setItem('kcg-gear-fr', JSON.stringify([...filterRisk])); + localStorage.setItem('kcg-gear-fps', JSON.stringify([...filterParentStatus])); + localStorage.setItem('kcg-gear-fp', JSON.stringify([...filterPermit])); + localStorage.setItem('kcg-gear-fmn', JSON.stringify(filterMemberMin)); + if (filterMemberMax !== Infinity) localStorage.setItem('kcg-gear-fmx', JSON.stringify(filterMemberMax)); + else localStorage.removeItem('kcg-gear-fmx'); + }, [filterOpen, filterZone, filterStatus, filterRisk, filterParentStatus, filterPermit, filterMemberMin, filterMemberMax]); const loadData = useCallback(async () => { setLoading(true); setError(''); try { - const res = await fetchGroups(); + const res = await fetchGroups('GEAR'); setServiceAvailable(res.serviceAvailable); - setGroups(res.items.filter( - (i) => i.groupType === 'GEAR_IN_ZONE' || i.groupType === 'GEAR_OUT_ZONE', - )); + setGroups(res.items); } catch (e: unknown) { setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다'); setServiceAvailable(false); @@ -113,39 +276,175 @@ export function GearDetection() { useEffect(() => { loadData(); }, [loadData]); const DATA: Gear[] = useMemo( - () => groups.map((g, i) => mapGroupToGear(g, i)), - [groups], + () => groups.map((g, i) => mapGroupToGear(g, i, t, lang)), + [groups, t, lang], + ); + + // 필터 옵션 (고유값 추출) + const filterOptions = useMemo(() => ({ + zones: [...new Set(DATA.map(d => d.zone))].sort(), + statuses: [...new Set(DATA.map(d => d.status))], + risks: [...new Set(DATA.map(d => d.risk))], + parentStatuses: [...new Set(DATA.map(d => d.parentStatus))], + permits: [...new Set(DATA.map(d => d.permit))], + maxMember: DATA.reduce((max, d) => Math.max(max, d.memberCount), 0), + }), [DATA]); + + const hasActiveFilter = checkFilterCount > 0 || (filterMemberMin > 2 || (filterMemberMax !== Infinity && filterMemberMax < filterOptions.maxMember)); + + // 데이터 로드 후 멤버 슬라이더 최대값 초기화 + useEffect(() => { + if (filterOptions.maxMember > 0 && filterMemberMax === Infinity) { + setFilterMemberMax(filterOptions.maxMember); + } + }, [filterOptions.maxMember, filterMemberMax]); + + // ── 필터 적용 ── + const filteredData = useMemo(() => { + const effMax = filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax; + return DATA.filter(d => + (filterZone.size === 0 || filterZone.has(d.zone)) && + (filterStatus.size === 0 || filterStatus.has(d.status)) && + (filterRisk.size === 0 || filterRisk.has(d.risk)) && + (filterParentStatus.size === 0 || filterParentStatus.has(d.parentStatus)) && + (filterPermit.size === 0 || filterPermit.has(d.permit)) && + d.memberCount >= filterMemberMin && d.memberCount <= effMax, + ); + }, [DATA, filterZone, filterStatus, filterRisk, filterParentStatus, filterPermit, filterMemberMin, filterMemberMax, filterOptions.maxMember]); + + const selectedGear = useMemo( + () => DATA.find(g => g.id === selectedId) ?? null, + [DATA, selectedId], ); const mapRef = useRef(null); - const buildLayers = useCallback(() => [ - ...createStaticLayers(), - createRadiusLayer( - 'gear-radius', - DATA.filter(g => g.risk === '고위험').map(g => ({ - lat: g.lat, - lng: g.lng, - radius: 6000, - color: RISK_HEX[g.risk] || "#64748b", - })), - 0.1, - ), - createMarkerLayer( - 'gear-markers', - DATA.map(g => ({ - lat: g.lat, - lng: g.lng, - color: RISK_HEX[g.risk] || "#64748b", - radius: g.risk === '고위험' ? 1200 : 800, - label: `${g.id} ${g.type}`, - } as MarkerData)), - ), - ], [DATA]); + // overlay Proxy ref — mapRef.current.overlay를 항상 최신으로 참조 + // iran 패턴: 리플레이 훅이 overlay.setProps() 직접 호출 + const overlayRef = useMemo>(() => ({ + get current() { return mapRef.current?.overlay ?? null; }, + }), []); - useMapLayers(mapRef, buildLayers, [DATA]); + const replayGroupKey = useGearReplayStore(s => s.groupKey); + const isReplayActive = !!replayGroupKey; + + const buildLayers = useCallback(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const layers: any[] = [ + // 1. 정적 레이어 (EEZ + NLL) + ...createStaticLayers(), + + // 2. 특정해역 I~IV 폴리곤 (항상 표시) + createGeoJsonLayer('fishing-zones', fishingZonesGeoJson, '#6366f1', { + fillOpacity: 15, + lineWidth: 2, + pickable: false, + }), + ]; + + if (isReplayActive) { + // ── 리플레이 모드: 정적 레이어만 (리플레이 훅이 나머지 직접 관리) ── + // 비선택 어구 그룹 중심 (흐린 마름모) — 위치 참조용 + layers.push(createGearIconLayer( + 'gear-dim', + DATA.filter(g => g.groupKey !== replayGroupKey).map(g => ({ + lat: g.lat, lon: g.lng, color: '#475569', size: 10, + } as GearIconData)), + )); + } else if (selectedId) { + // ── 선택 모드 (리플레이 비활성) ── + const sel = DATA.find(g => g.id === selectedId); + + // 선택된 어구 그룹 폴리곤 강조 + if (sel?.polygon) { + layers.push(createGearPolygonLayer('gear-polygon-selected', [{ + polygon: sel.polygon, + color: '#f59e0b', + label: `${sel.id} ${sel.type}`, + risk: sel.risk, + }])); + } + + // 멤버 아이콘: 선박(PARENT)=삼각형+COG, 어구(GEAR)=마름모 + if (sel && sel.members.length > 0) { + const ships = sel.members.filter(m => m.lat != null && m.lon != null && (m.isParent || m.role === 'PARENT')); + const gears = sel.members.filter(m => m.lat != null && m.lon != null && !m.isParent && m.role !== 'PARENT'); + + if (ships.length > 0) { + layers.push(createShipIconLayer('sel-ships', ships.map(m => ({ + lat: m.lat!, lon: m.lon!, + cog: (m as Record).cog as number | undefined, + color: '#06b6d4', size: 28, + label: `${m.mmsi} ${m.name ?? ''}`, + isParent: true, + } as ShipIconData)))); + } + + if (gears.length > 0) { + layers.push(createGearIconLayer('sel-gears', gears.map(m => ({ + lat: m.lat!, lon: m.lon!, + color: '#f59e0b', size: 18, + label: `${m.mmsi} ${m.name ?? ''}`, + } as GearIconData)))); + } + } + + // 비선택 어구 그룹 중심 (흐린 마름모) + layers.push(createGearIconLayer( + 'gear-dim', + DATA.filter(g => g.id !== selectedId).map(g => ({ + lat: g.lat, lon: g.lng, color: '#475569', size: 10, + } as GearIconData)), + )); + } else { + // ── 기본 모드: 모든 어구 폴리곤 + 아이콘 ── + layers.push(createGearPolygonLayer( + 'gear-polygons', + DATA.filter(g => g.polygon != null).map(g => ({ + polygon: g.polygon, + color: RISK_HEX[g.risk] || '#64748b', + label: `${g.id} ${g.type}`, + risk: g.risk, + })), + )); + + // 어구 그룹 중심 마름모 아이콘 + layers.push(createGearIconLayer( + 'gear-center-icons', + DATA.map(g => ({ + lat: g.lat, lon: g.lng, + color: RISK_HEX[g.risk] || '#64748b', + size: g.risk === '고위험' ? 20 : 14, + label: `${g.id} ${g.owner}`, + } as GearIconData)), + )); + } + + return layers; + }, [DATA, selectedId, isReplayActive, replayGroupKey]); + + // 리플레이 비활성 시만 useMapLayers가 overlay 제어 + // 리플레이 활성 시 useGearReplayLayers가 overlay 독점 (iran 패턴: 단일 렌더링 경로) + useEffect(() => { + if (isReplayActive) return; // replay hook이 overlay 독점 + const raf = requestAnimationFrame(() => { + mapRef.current?.overlay?.setProps({ layers: buildLayers() }); + }); + return () => cancelAnimationFrame(raf); + }, [buildLayers, isReplayActive]); + + useGearReplayLayers(overlayRef, buildLayers); + + // 수역별 통계 + const zoneStats = useMemo(() => { + const stats: Record = {}; + filteredData.forEach(d => { stats[d.zone] = (stats[d.zone] || 0) + 1; }); + return stats; + }, [filteredData]); return ( + <> + setSelectedId(null)} /> )} - {error && ( -
에러: {error}
- )} + {error &&
에러: {error}
} {loading && (
@@ -171,33 +468,152 @@ export function GearDetection() {
)} -
+ {/* 요약 배지 */} +
{[ - { l: '전체 어구 그룹', v: DATA.length, c: 'text-heading' }, - { l: '불법 의심', v: DATA.filter(d => d.status.includes('불법')).length, c: 'text-red-400' }, - { l: '확인 중', v: DATA.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' }, - { l: '정상', v: DATA.filter(d => d.status === '정상').length, c: 'text-green-400' }, + { l: '전체 어구 그룹', v: filteredData.length, c: 'text-heading' }, + { l: '불법 의심', v: filteredData.filter(d => d.status.includes('불법')).length, c: 'text-red-400' }, + { l: '확인 중', v: filteredData.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' }, + { l: '정상', v: filteredData.filter(d => d.status === '정상').length, c: 'text-green-400' }, ].map(k => ( -
+
{k.v}{k.l}
))}
- + {/* 수역별 분포 */} +
+ {Object.entries(zoneStats).sort(([,a],[,b]) => b - a).map(([zone, cnt]) => ( + + {getZoneCodeLabel(zone, t, lang)} {cnt}건 + + ))} +
+ + {/* 필터 토글 버튼 */} +
+ + {hasActiveFilter && ( + <> + {filteredData.length}/{DATA.length}건 + + + )} +
+ + {/* 필터 패널 (접기/펼치기) */} + {filterOpen && ( +
+ ({ value: z, label: getZoneCodeLabel(z, t, lang) }))} /> + ({ value: s, label: s }))} /> + ({ value: r, label: r }))} /> + ({ + value: s, + label: s === 'DIRECT_PARENT_MATCH' ? '직접매칭' : s === 'AUTO_PROMOTED' ? '자동승격' + : s === 'REVIEW_REQUIRED' ? '심사필요' : s === 'UNRESOLVED' ? '미결정' : s, + }))} /> + ({ value: p, label: getPermitStatusLabel(p, tc, lang) }))} /> + + {/* 멤버 수 범위 슬라이더 */} + {filterOptions.maxMember > 2 && ( +
+
+ 멤버 수 {filterMemberMin}~{filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}척 +
+
+ {filterMemberMin} + setFilterMemberMin(Number(e.target.value))} + aria-label="최소 멤버 수" + className="flex-1 h-1 accent-primary cursor-pointer" /> + setFilterMemberMax(Number(e.target.value))} + aria-label="최대 멤버 수" + className="flex-1 h-1 accent-primary cursor-pointer" /> + {filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax} +
+
+ )} + + {/* 패널 내 초기화 */} +
+ {filteredData.length}/{DATA.length}건 표시 + +
+
+ )} + + { + const newId = row.id === selectedId ? null : row.id; + setSelectedId(newId); + // 선택 시 지도 중심 이동 + if (newId) { + const gear = DATA.find(g => g.id === newId); + if (gear && mapRef.current?.map) { + mapRef.current.map.flyTo({ + center: [gear.lng, gear.lat], + zoom: 10, + duration: 1000, + }); + } + } + }} + /> {/* 어구 탐지 위치 지도 */} - + {/* 범례 */}
-
어구 위험도
+
범례
-
고위험 (불법 의심/확정)
+
고위험 어구 그룹
중위험 (확인 중)
안전 (정상)
+
+
특정해역
+
해역 I (동해)
+
해역 II (남해)
+
해역 III (서남해)
+
해역 IV (서해)
+
EEZ
NLL
@@ -206,10 +622,13 @@ export function GearDetection() {
{DATA.length}건 - 어구 탐지 위치 + 어구 그룹
+ {/* 리플레이 컨트롤러 (활성 시 표시) */} + + ); } diff --git a/frontend/src/features/detection/components/GearDetailPanel.tsx b/frontend/src/features/detection/components/GearDetailPanel.tsx new file mode 100644 index 0000000..9588f65 --- /dev/null +++ b/frontend/src/features/detection/components/GearDetailPanel.tsx @@ -0,0 +1,641 @@ +/** + * GearDetailPanel — 어구 그룹 판정 상세 사이드 패널 + * + * 테이블 행 클릭 시 우측에 슬라이드 표시. + * G코드 위반 내역, 어구 그룹 정보, 모선 추론 결과 + correlation 상세, + * 궤적 리플레이 시작 버튼을 종합 표시. + */ +import { useEffect, useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { + getGearViolationIntent, + getGearViolationLabel, + getGearViolationDesc, + GEAR_VIOLATION_CODES, +} from '@shared/constants/gearViolationCodes'; +import { getZoneCodeIntent, getZoneCodeLabel, getZoneAllowedGears } from '@shared/constants/zoneCodes'; +import { getGearJudgmentIntent } from '@shared/constants/permissionStatuses'; +import { X, Anchor, MapPin, ShieldAlert, Users, Ship, Play, TrendingUp, Loader2, CheckCircle, XCircle, BarChart3 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useSettingsStore } from '@stores/settingsStore'; +import { fetchGroupCorrelations, fetchVesselTracks, fetchCandidateMetrics, resolveParent, type CandidateMetricItem } from '@/services/vesselAnalysisApi'; +import { useGearReplayStore } from '@stores/gearReplayStore'; + +interface CorrelationItem { + targetMmsi: string; + targetName: string; + targetType: string; + score: number; + streak: number; + freezeState: string; + proximityRatio: number; + visitScore: number; + headingCoherence: number; + modelName: string; +} + +interface GearData { + id: string; + groupKey: string; + type: string; + owner: string; + zone: string; + status: string; + permit: string; + risk: string; + lat: number; + lng: number; + parentStatus: string; + parentMmsi: string; + confidence: string; + gCodes: string[]; + gearViolationScore: number; + gearViolationEvidence: Record>; + pairTrawlDetected: boolean; + pairTrawlPairMmsi: string; + memberCount: number; + members: Array<{ mmsi: string; name?: string; lat?: number; lon?: number; role?: string; isParent?: boolean }>; + allowedGears: string[]; +} + +interface GearDetailPanelProps { + gear: GearData | null; + onClose: () => void; +} + +export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) { + const navigate = useNavigate(); + const { t } = useTranslation('detection'); + const lang = useSettingsStore((s) => s.language); + const [correlations, setCorrelations] = useState([]); + const [corrLoading, setCorrLoading] = useState(false); + const [selectedCandidates, setSelectedCandidates] = useState>(new Set()); + const [selectedDetail, setSelectedDetail] = useState(null); // 상세 보기 중인 후보 MMSI + const [detailMetrics, setDetailMetrics] = useState([]); + const [detailLoading, setDetailLoading] = useState(false); + const [resolveLoading, setResolveLoading] = useState(false); + const [resolveMsg, setResolveMsg] = useState(''); + const replayGroupKey = useGearReplayStore(s => s.groupKey); + + const toggleCandidate = useCallback((mmsi: string) => { + setSelectedCandidates(prev => { + const next = new Set(prev); + if (next.has(mmsi)) next.delete(mmsi); else next.add(mmsi); + return next; + }); + }, []); + + // 후보 상세 메트릭 로드 + const loadCandidateDetail = useCallback(async (mmsi: string) => { + if (!gear) return; + setSelectedDetail(mmsi); + setDetailLoading(true); + setResolveMsg(''); + try { + const data = await fetchCandidateMetrics(gear.groupKey, mmsi); + setDetailMetrics(data.items ?? []); + } catch { + setDetailMetrics([]); + } finally { + setDetailLoading(false); + } + }, [gear]); + + // 모선 확정/제외 처리 + const handleResolve = useCallback(async (action: 'confirm' | 'reject') => { + if (!gear || !selectedDetail) return; + setResolveLoading(true); + setResolveMsg(''); + try { + const result = await resolveParent(gear.groupKey, action, selectedDetail); + if (result.ok) { + setResolveMsg(action === 'confirm' ? '모선 확정 완료' : '후보 제외 완료'); + } else { + setResolveMsg(result.message ?? '처리 실패'); + } + } catch { + setResolveMsg('요청 실패'); + } finally { + setResolveLoading(false); + } + }, [gear, selectedDetail]); + + const loadCorrelations = useCallback(async (groupKey: string) => { + setCorrLoading(true); + try { + const data = await fetchGroupCorrelations(groupKey, 0.3); + // API 응답을 CorrelationItem 형태로 매핑 + const items = Array.isArray(data) ? data : ((data as Record)?.items as unknown[]) ?? []; + // 같은 MMSI가 여러 모델에서 중복 → default 모델 우선, 없으면 첫 번째 + const byMmsi = new Map(); + for (const item of items) { + const d = item as Record; + const mmsi = String(d.targetMmsi ?? ''); + const mapped: CorrelationItem = { + targetMmsi: mmsi, + targetName: String(d.target_name ?? d.targetName ?? ''), + targetType: String(d.target_type ?? d.targetType ?? ''), + score: Number(d.score ?? d.current_score ?? d.currentScore ?? 0), + streak: Number(d.streak ?? d.streak_count ?? d.streakCount ?? 0), + freezeState: String(d.freeze_state ?? d.freezeState ?? 'ACTIVE'), + proximityRatio: Number(d.proximity_ratio ?? d.proximityRatio ?? 0), + visitScore: Number(d.visit_score ?? d.visitScore ?? 0), + headingCoherence: Number(d.heading_coherence ?? d.headingCoherence ?? 0), + modelName: String(d.model_name ?? d.modelName ?? 'default'), + }; + const existing = byMmsi.get(mmsi); + if (!existing || mapped.modelName === 'default' || mapped.score > existing.score) { + byMmsi.set(mmsi, mapped); + } + } + const sorted = Array.from(byMmsi.values()).sort((a, b) => b.score - a.score); + setCorrelations(sorted); + // 최고 일치율 후보 자동 선택 + if (sorted.length > 0) { + loadCandidateDetail(sorted[0].targetMmsi); + } + } catch { + setCorrelations([]); + } finally { + setCorrLoading(false); + } + }, [loadCandidateDetail]); + + useEffect(() => { + if (gear?.groupKey) { + loadCorrelations(gear.groupKey); + } else { + setCorrelations([]); + } + }, [gear?.groupKey, loadCorrelations]); + + const [replayLoading, setReplayLoading] = useState(false); + + const handleStartReplay = useCallback(async () => { + if (!gear) return; + setReplayLoading(true); + try { + // 자체 백엔드 API로 group history + correlation 조회 + const groupKeyEnc = encodeURIComponent(gear.groupKey); + const [detailRes, tracksRes] = await Promise.all([ + fetch(`/api/vessel-analysis/groups/${groupKeyEnc}/detail`) + .then(r => r.json()).catch(() => ({ history: [] })), + fetch(`/api/vessel-analysis/groups/${groupKeyEnc}/correlations?minScore=0.1`) + .then(r => r.json()).catch(() => ({ items: [] })), + ]); + // detailRes.history → frames 변환 + const historyRes = { frames: (detailRes.history ?? []) }; + + const frames = (historyRes.frames ?? []).map((f: Record) => ({ + snapshotTime: String(f.snapshotTime ?? ''), + centerLat: Number(f.centerLat ?? 0), + centerLon: Number(f.centerLon ?? 0), + memberCount: Number(f.memberCount ?? 0), + polygon: f.polygon ?? null, + members: Array.isArray(f.members) ? f.members : [], + })); + + // correlation 응답 → loadHistory용 CorrelationItem 형태로 매핑 + const corrItems = tracksRes.items ?? []; + const replayCorrelations = Array.isArray(corrItems) ? corrItems.map((v: Record) => ({ + targetMmsi: String(v.targetMmsi ?? ''), + targetName: String(v.targetName ?? ''), + score: Number(v.score ?? v.currentScore ?? 0), + freezeState: String(v.freezeState ?? 'ACTIVE'), + })) : []; + + if (frames.length === 0) { + frames.push({ + snapshotTime: new Date().toISOString(), + centerLat: gear.lat, + centerLon: gear.lng, + memberCount: gear.memberCount, + polygon: null, + members: gear.members ?? [], + }); + } + + // 선택된 후보 선박 항적 조회 (24시간) + let candidateTracks: { vesselId: string; shipName: string; geometry: [number, number][]; timestamps: string[] }[] = []; + if (selectedCandidates.size > 0) { + const now = new Date(); + const h24ago = new Date(now.getTime() - 24 * 3600_000); + try { + candidateTracks = await fetchVesselTracks( + [...selectedCandidates], + h24ago.toISOString(), + now.toISOString(), + ); + } catch { + // 항적 조회 실패해도 리플레이는 진행 + } + } + + useGearReplayStore.getState().loadHistory(gear.groupKey, frames, replayCorrelations, candidateTracks); + useGearReplayStore.getState().play(); + } catch { + // silent fallback + } finally { + setReplayLoading(false); + } + }, [gear, selectedCandidates]); + + if (!gear) return null; + + const allowedGears = gear.allowedGears.length > 0 + ? gear.allowedGears + : getZoneAllowedGears(gear.zone); + + const parentStatusLabel = + gear.parentStatus === 'DIRECT_PARENT_MATCH' ? '직접매칭' : + gear.parentStatus === 'AUTO_PROMOTED' ? '자동승격' : + gear.parentStatus === 'REVIEW_REQUIRED' ? '심사필요' : + gear.parentStatus === 'UNRESOLVED' ? '미결정' : + gear.parentStatus; + + const parentStatusIntent: 'success' | 'info' | 'warning' | 'muted' = + gear.parentStatus === 'DIRECT_PARENT_MATCH' ? 'success' : + gear.parentStatus === 'AUTO_PROMOTED' ? 'info' : + gear.parentStatus === 'REVIEW_REQUIRED' ? 'warning' : + 'muted'; + + const hasPairTrawl = gear.pairTrawlDetected || gear.gCodes.includes('G-06'); + const isReplayActive = replayGroupKey === gear.groupKey; + + return ( +
+ {/* 헤더 */} +
+
+ + 어구 판정 상세 + {gear.id} + + {getZoneCodeLabel(gear.zone, t, lang)} + +
+ +
+ +
+ {/* G코드 위반 내역 */} + {gear.gCodes.length > 0 && ( +
+
+ + G코드 위반 내역 + 총 {gear.gearViolationScore}점 +
+
+ {gear.gCodes.map((code) => { + const meta = GEAR_VIOLATION_CODES[code as keyof typeof GEAR_VIOLATION_CODES]; + return ( +
+ {code} +
+
{getGearViolationLabel(code, t, lang)}
+
{getGearViolationDesc(code, lang)}
+
+ {meta && +{meta.score}pt} +
+ ); + })} +
+
+ )} + + {/* 어구 그룹 정보 */} +
+
+ + 어구 그룹 정보 +
+
+ 그룹 키 + {gear.groupKey} + 그룹 유형 + {gear.type} + 모선/소유자 + {gear.owner} + 구성원 수 + {gear.memberCount > 0 ? `${gear.memberCount}척` : '-'} + 설치 수역 + + {getZoneCodeLabel(gear.zone, t, lang)} + + 판정 상태 + + {gear.status} + + 허용 어구 + {allowedGears.length > 0 ? allowedGears.join(', ') : '없음'} + 위치 + + {gear.lat.toFixed(4)}°N {gear.lng.toFixed(4)}°E + +
+
+ + {/* 모선 추론 정보 */} +
+
+ + 모선 추론 + {parentStatusLabel} +
+
+ 추정 모선 + + {gear.parentMmsi !== '-' && gear.parentMmsi ? ( + + ) : '-'} + + 후보 수 + {gear.confidence} +
+
+ + {/* 모선 추론 후보 상세 (Correlation) */} +
+
+ + 추론 후보 상세 + {corrLoading && } + {correlations.length}건 +
+ {correlations.length > 0 ? ( +
+ {correlations.sort((a, b) => b.score - a.score).map((c, i) => ( +
{ + // 체크박스, MMSI 링크 클릭은 전파 방지됨 → 나머지 영역 클릭 시 상세 로드 + if ((e.target as HTMLElement).closest('input, a, button')) return; + loadCandidateDetail(c.targetMmsi); + }} + className={`py-1.5 px-2 rounded text-xs cursor-pointer transition-colors ${ + selectedDetail === c.targetMmsi + ? 'bg-purple-500/10 border border-purple-500/30' + : c.targetMmsi === gear.parentMmsi + ? 'bg-cyan-500/10 border border-cyan-500/30' + : 'hover:bg-surface-overlay' + }`}> +
+ toggleCandidate(c.targetMmsi)} + className="w-3 h-3 rounded border-border accent-emerald-500 shrink-0 cursor-pointer" + aria-label={`${c.targetMmsi} 리플레이 선택`} /> + + {c.targetName} +
+
+
= 0.72 ? '#10b981' : c.score >= 0.5 ? '#f59e0b' : '#64748b', + }} /> +
+ + {(c.score * 100).toFixed(0)}% + + + {c.freezeState === 'ACTIVE' ? '활성' : c.freezeState.slice(0, 4)} + +
+
+ 근접: {(c.proximityRatio * 100).toFixed(0)}% + {c.visitScore > 0 && 방문: {(c.visitScore * 100).toFixed(0)}%} + {c.headingCoherence > 0 && 방향: {(c.headingCoherence * 100).toFixed(0)}%} + 연속: {c.streak}회 + + {c.targetType === 'VESSEL' ? '선박' : c.targetType === 'GEAR_BUOY' ? '어구' : c.targetType} + +
+
+ ))} +
+ ) : !corrLoading ? ( +
추론 후보 데이터 없음
+ ) : null} + {correlations.length > 0 && ( +
+ 근접: 어구-선박 근접도 + 방문: 어구 구역 방문 빈도 + 방향: 침로 일관성 +
+ )} +
+ + {/* ── 후보 상세 검토 패널 ── */} + {selectedDetail && (() => { + const cand = correlations.find(c => c.targetMmsi === selectedDetail); + if (!cand) return null; + // 최근 메트릭 평균 계산 + const avg = (arr: (number | null)[]): number | null => { + const valid = arr.filter((v): v is number => v != null && v > 0); + return valid.length > 0 ? valid.reduce((a, b) => a + b, 0) / valid.length : null; + }; + const avgProximity = avg(detailMetrics.map(m => m.proximityRatio)); + const avgVisit = avg(detailMetrics.map(m => m.visitScore)); + const avgActivity = avg(detailMetrics.map(m => m.activitySync)); + const avgDtw = avg(detailMetrics.map(m => m.dtwSimilarity)); + const avgSpeed = avg(detailMetrics.map(m => m.speedCorrelation)); + const avgHeading = avg(detailMetrics.map(m => m.headingCoherence)); + const avgDrift = avg(detailMetrics.map(m => m.driftSimilarity)); + const shadowStayCount = detailMetrics.filter(m => m.shadowStay).length; + const shadowReturnCount = detailMetrics.filter(m => m.shadowReturn).length; + + const pct = (v: number | null) => v != null ? `${(v * 100).toFixed(1)}%` : '-'; + const bar = (v: number | null, color: string) => ( +
+
+
+ ); + + return ( +
+ {/* 헤더 */} +
+ + 후보 검토 + {cand.targetMmsi} + {cand.targetName} +
+ + {/* 종합 점수 */} +
+
+
= 0.72 ? 'text-green-400' : cand.score >= 0.5 ? 'text-yellow-400' : 'text-hint'}`}> + {(cand.score * 100).toFixed(1)}% +
+
종합 일치율
+
+
+
{cand.streak}회
+
연속 관측
+
+
+
{detailMetrics.length}건
+
raw 메트릭
+
+
+ + {/* 점수 근거 상세 */} + {detailLoading ? ( +
+ ) : ( +
+
관측 지표 (최근 {detailMetrics.length}건 평균)
+ {[ + { label: '근접도', value: avgProximity, color: '#06b6d4', desc: '어구-선박 거리 근접 비율' }, + { label: '방문 점수', value: avgVisit, color: '#8b5cf6', desc: '어구 구역 방문 빈도' }, + { label: '활동 동기화', value: avgActivity, color: '#f59e0b', desc: '어구-선박 활동 시간 일치' }, + { label: 'DTW 유사도', value: avgDtw, color: '#ec4899', desc: '궤적 형태 유사도' }, + { label: '속도 상관', value: avgSpeed, color: '#14b8a6', desc: '속도 변화 패턴 일치' }, + { label: '침로 일관성', value: avgHeading, color: '#3b82f6', desc: '방향 변화 일치' }, + { label: '드리프트 유사도', value: avgDrift, color: '#64748b', desc: '표류 패턴 유사' }, + ].map(({ label, value, color, desc }) => ( +
+ {label} +
{bar(value, color)}
+ {pct(value)} + {desc} +
+ ))} + + {/* 보정 지표 */} +
보정 지표
+
+ 섀도우 체류 + {shadowStayCount}/{detailMetrics.length}건 + 섀도우 복귀 + {shadowReturnCount}/{detailMetrics.length}건 + 동결 상태 + {cand.freezeState === 'ACTIVE' ? '활성' : cand.freezeState} + 선박 유형 + {cand.targetType === 'VESSEL' ? '선박' : cand.targetType === 'GEAR_BUOY' ? '어구' : cand.targetType} +
+
+ )} + + {/* 확정/제외 버튼 */} + {resolveMsg && ( +
+ {resolveMsg} +
+ )} +
+ + +
+
+ ); + })()} + + {/* 궤적 리플레이 버튼 */} + + + {/* 쌍끌이 감지 정보 */} + {hasPairTrawl && ( +
+
+ + 쌍끌이 트롤 공조 + G-06 +
+
+ 상대 선박 + + {gear.pairTrawlPairMmsi ? ( + + ) : '-'} + + {gear.gearViolationEvidence['G-06'] && (() => { + const ev = gear.gearViolationEvidence['G-06']; + return ( + <> + {ev.sync_duration_min != null && ( + <> + 동기 지속 + {String(ev.sync_duration_min)}분 + + )} + {ev.mean_separation_nm != null && ( + <> + 평균 간격 + {(Number(ev.mean_separation_nm) * 1852).toFixed(0)}m + + )} + + ); + })()} +
+
+ )} + + {/* 위치 + 액션 */} +
+
+ + 위치 +
+
+ 위도 + {gear.lat.toFixed(6)}°N + 경도 + {gear.lng.toFixed(6)}°E +
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/detection/components/GearReplayController.tsx b/frontend/src/features/detection/components/GearReplayController.tsx new file mode 100644 index 0000000..d8e571a --- /dev/null +++ b/frontend/src/features/detection/components/GearReplayController.tsx @@ -0,0 +1,179 @@ +/** + * GearReplayController — 어구 그룹 24시간 궤적 재생 컨트롤러 + * + * 맵 위에 absolute 포지셔닝으로 표시. + * Zustand subscribe 패턴으로 DOM 직접 업데이트 → 재생 중 React re-render 없음. + */ +import { useEffect, useRef } from 'react'; +import { Button } from '@shared/components/ui/button'; +import { useGearReplayStore } from '@stores/gearReplayStore'; +import { Play, Pause, X } from 'lucide-react'; + +interface GearReplayControllerProps { + onClose: () => void; +} + +const SPEED_OPTIONS = [1, 2, 5, 10] as const; +type SpeedOption = (typeof SPEED_OPTIONS)[number]; + +function formatEpochTime(epochMs: number): string { + if (epochMs === 0) return '--:--'; + const d = new Date(epochMs); + const MM = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + return `${MM}/${dd} ${hh}:${mm}`; +} + +export function GearReplayController({ onClose }: GearReplayControllerProps) { + const play = useGearReplayStore((s) => s.play); + const pause = useGearReplayStore((s) => s.pause); + const seek = useGearReplayStore((s) => s.seek); + const setSpeed = useGearReplayStore((s) => s.setSpeed); + const isPlaying = useGearReplayStore((s) => s.isPlaying); + const playbackSpeed = useGearReplayStore((s) => s.playbackSpeed); + const startTime = useGearReplayStore((s) => s.startTime); + const endTime = useGearReplayStore((s) => s.endTime); + const snapshotRanges = useGearReplayStore((s) => s.snapshotRanges); + const dataStartTime = useGearReplayStore((s) => s.dataStartTime); + const dataEndTime = useGearReplayStore((s) => s.dataEndTime); + + // DOM refs for direct updates — no React state during playback + const progressBarRef = useRef(null); + const timeLabelRef = useRef(null); + const trackRef = useRef(null); + + // Subscribe to currentTime changes and update DOM directly + useEffect(() => { + const unsubscribe = useGearReplayStore.subscribe( + (s) => s.currentTime, + (currentTime) => { + const duration = endTime - startTime; + const pct = duration > 0 ? ((currentTime - startTime) / duration) * 100 : 0; + + if (progressBarRef.current) { + progressBarRef.current.style.width = `${Math.min(100, pct)}%`; + } + if (timeLabelRef.current) { + timeLabelRef.current.textContent = formatEpochTime(currentTime); + } + }, + ); + return unsubscribe; + }, [startTime, endTime]); + + // Handle click on track to seek + const handleTrackClick = (e: React.MouseEvent) => { + if (!trackRef.current) return; + const rect = trackRef.current.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + seek(startTime + ratio * (endTime - startTime)); + }; + + const handlePlayPause = () => { + if (isPlaying) { + pause(); + } else { + play(); + } + }; + + const duration = endTime - startTime; + const initialPct = + duration > 0 + ? ((useGearReplayStore.getState().currentTime - startTime) / duration) * 100 + : 0; + + return ( +
+
+ {/* Play / Pause */} + + ))} +
+ + {/* Start time */} + {formatEpochTime(startTime)} + + {/* Progress track */} +
+ {/* 스냅샷 틱마크 */} + {snapshotRanges.map((r, i) => ( +
+ ))} +
+
+ + {/* End time */} + {formatEpochTime(endTime)} + + {/* Current time label */} + + {formatEpochTime(useGearReplayStore.getState().currentTime)} + + + {/* Close */} + +
+
+ ); +} diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts new file mode 100644 index 0000000..0f14b6f --- /dev/null +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -0,0 +1,402 @@ +/** + * useGearReplayLayers — 어구 궤적 리플레이 레이어 빌더 훅 + * + * iran 프로젝트 useReplayLayer.ts 패턴 그대로 적용: + * + * 1. animationStore rAF 루프 → set({ currentTime }) 매 프레임 (gearReplayStore) + * 2. zustand.subscribe(currentTime) → renderFrame() + * - 재생 중: ~10fps 쓰로틀 + pendingRafId로 다음 프레임 보장 (프레임 드롭 방지) + * - seek/정지: 즉시 렌더 + * 3. renderFrame() → 레이어 빌드 → overlay.setProps({ layers }) 직접 호출 + * + * 레이어 구성 (iran 대비 KCG 적용): + * - PathLayer: 중심 궤적 (gold) — iran의 정적 PathLayer에 대응 + * - TripsLayer: 멤버 궤적 fade trail — iran과 동일 + * - IconLayer: 멤버 현재 위치 (보간) — iran의 가상 선박 레이어에 대응 + * - PolygonLayer: 현재 폴리곤 (보간으로 확장/축소 애니메이션) + * - TextLayer: MMSI 라벨 + * + * 제거된 것: 멤버 배경 PathLayer (TripsLayer와 중복), 멤버-중심 연결선 (불필요) + */ +import { useEffect, useRef, useCallback } from 'react'; +import type { Layer } from 'deck.gl'; +import { ScatterplotLayer, IconLayer, PolygonLayer, TextLayer } from 'deck.gl'; +import type { MapboxOverlay } from '@deck.gl/mapbox'; +import { useGearReplayStore } from '@stores/gearReplayStore'; +import { + findFrameAtTime, + interpolateFromTripsData, + computeConvexHull, + type MemberPosition, +} from '@stores/gearReplayPreprocess'; +import { createTripsLayer } from '@lib/map/layers/trips'; + +// ── SVG Data URI ── + +const ICON_SIZE = 64; + +const SHIP_URI = (() => { + const s = ICON_SIZE; + const svg = ``; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +})(); + +const GEAR_URI = (() => { + const s = ICON_SIZE; + const svg = ``; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +})(); + +// ── 색상 ── + +const CYAN: [number, number, number, number] = [6, 182, 212, 220]; +const AMBER: [number, number, number, number] = [245, 158, 11, 200]; +const SLATE: [number, number, number, number] = [148, 163, 184, 120]; +const POLYGON_FILL: [number, number, number, number] = [245, 158, 11, 30]; +const POLYGON_STROKE: [number, number, number, number] = [245, 158, 11, 120]; + +const RENDER_INTERVAL_MS = 100; // iran과 동일: ~10fps 쓰로틀 + +function memberIconColor(m: MemberPosition): [number, number, number, number] { + if (m.stale) return SLATE; + if (m.isParent) return CYAN; + if (m.isGear) return AMBER; + return [148, 163, 184, 200]; +} + +// ── 훅 ── + +export function useGearReplayLayers( + overlayRef: React.RefObject, + buildBaseLayers: () => Layer[], +) { + const frameCursorRef = useRef(0); + // iran의 positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색) + const memberCursorsRef = useRef(new Map()); + + // buildBaseLayers를 최신 참조로 유지 + const baseLayersRef = useRef(buildBaseLayers); + baseLayersRef.current = buildBaseLayers; + + /** + * renderFrame — iran의 renderFrame과 동일 구조: + * 1. 현재 위치 계산 (보간) + * 2. 레이어 빌드 + * 3. overlay.setProps({ layers }) 직접 호출 + */ + const renderFrame = useCallback(() => { + const overlay = overlayRef.current; + if (!overlay) return; + + const state = useGearReplayStore.getState(); + const { + historyFrames, frameTimes, memberTripsData, memberMetadata, + currentTime, startTime, correlationItems, + candidateTripsData, candidateMetadata, + } = state; + + if (historyFrames.length === 0) { + overlay.setProps({ layers: baseLayersRef.current() }); + return; + } + + // 현재 프레임 찾기 (폴리곤 보간용) + const { index: frameIdx, cursor: newCursor } = findFrameAtTime( + frameTimes, currentTime, frameCursorRef.current, + ); + frameCursorRef.current = newCursor; + + // 멤버 보간 — iran의 getCurrentVesselPositions 패턴: + // 프레임 기반이 아닌 멤버별 개별 타임라인에서 보간 → 빈 구간도 연속 보간 + const relativeTime = currentTime - startTime; + const members = interpolateFromTripsData( + memberTripsData, memberMetadata, relativeTime, memberCursorsRef.current, + ); + + // 멤버 위치 기반 convex hull 폴리곤 (프레임 보간이 아닌 실시간 생성) + const hullRing = computeConvexHull(members); + + const currentFrame = frameIdx >= 0 ? historyFrames[frameIdx] : null; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const replayLayers: any[] = []; + + // 1. TripsLayer — 멤버 궤적 fade trail (iran과 동일 패턴) + // TripsLayer가 자체적으로 부드러운 궤적 애니메이션 처리 + if (memberTripsData.length > 0) { + replayLayers.push(createTripsLayer( + 'replay-member-trails', + memberTripsData, + relativeTime, + 3_600_000, // 1시간 fade trail + )); + } + + // 4. 멤버 현재 위치 IconLayer (iran의 createVirtualShipLayers에 대응) + const ships = members.filter(m => m.isParent); + const gears = members.filter(m => m.isGear); + const others = members.filter(m => !m.isParent && !m.isGear); + + if (ships.length > 0) { + replayLayers.push(new IconLayer({ + id: 'replay-member-ships', + data: ships, + pickable: true, + iconAtlas: SHIP_URI, + iconMapping: { + ship: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true }, + }, + getIcon: () => 'ship', + getPosition: d => [d.lon, d.lat], + getSize: 24, + getAngle: d => -(d.cog ?? 0), + getColor: d => memberIconColor(d), + sizeUnits: 'pixels', + sizeMinPixels: 10, + billboard: false, + })); + } + + if (gears.length > 0) { + replayLayers.push(new IconLayer({ + id: 'replay-member-gears', + data: gears, + pickable: true, + iconAtlas: GEAR_URI, + iconMapping: { + gear: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE / 2, mask: true }, + }, + getIcon: () => 'gear', + getPosition: d => [d.lon, d.lat], + getSize: 16, + getAngle: 0, + getColor: d => memberIconColor(d), + sizeUnits: 'pixels', + sizeMinPixels: 6, + billboard: false, + })); + } + + if (others.length > 0) { + replayLayers.push(new IconLayer({ + id: 'replay-member-others', + data: others, + pickable: true, + iconAtlas: SHIP_URI, + iconMapping: { + ship: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true }, + }, + getIcon: () => 'ship', + getPosition: d => [d.lon, d.lat], + getSize: 18, + getAngle: d => -(d.cog ?? 0), + getColor: d => memberIconColor(d), + sizeUnits: 'pixels', + sizeMinPixels: 8, + billboard: false, + })); + } + + // 5. MMSI 라벨 + if (members.length > 0) { + replayLayers.push(new TextLayer({ + id: 'replay-member-labels', + data: members, + getPosition: d => [d.lon, d.lat], + getText: d => d.mmsi, + getColor: [255, 255, 255, 200], + getSize: 10, + getPixelOffset: [0, -18], + fontFamily: 'monospace', + fontWeight: 'bold', + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + sizeUnits: 'pixels', + sizeMinPixels: 8, + sizeMaxPixels: 12, + billboard: true, + })); + } + + // 6. 멤버 위치 기반 convex hull 폴리곤 + if (hullRing) { + replayLayers.push(new PolygonLayer({ + id: 'replay-polygon', + data: [{ ring: hullRing }], + getPolygon: d => d.ring, + getFillColor: POLYGON_FILL, + getLineColor: POLYGON_STROKE, + getLineWidth: 2, + lineWidthUnits: 'pixels', + lineWidthMinPixels: 1, + filled: true, + stroked: true, + })); + } + + // 7. 추론 후보 위치 (correlation) + if (correlationItems.length > 0 && currentFrame) { + const memberMap = new Map(currentFrame.members.map(m => [m.mmsi, m])); + const corrPositions = correlationItems + .filter(c => { + const m = memberMap.get(c.targetMmsi); + return m && m.lat != null && m.lon != null; + }) + .map(c => { + const m = memberMap.get(c.targetMmsi)!; + return { ...c, lon: m.lon, lat: m.lat }; + }); + + if (corrPositions.length > 0) { + replayLayers.push(new ScatterplotLayer({ + id: 'replay-corr-positions', + data: corrPositions, + getPosition: d => [d.lon, d.lat], + getFillColor: d => { + const s = d.score; + if (s >= 0.72) return [16, 185, 129, 180] as [number, number, number, number]; + if (s >= 0.5) return [245, 158, 11, 180] as [number, number, number, number]; + return [100, 116, 139, 140] as [number, number, number, number]; + }, + getRadius: d => 5 + d.score * 8, + radiusUnits: 'pixels', + radiusMinPixels: 4, + lineWidthMinPixels: 1, + stroked: true, + getLineColor: [255, 255, 255, 120], + getLineWidth: 1, + pickable: true, + })); + } + } + + // 8. 후보 선박 항적 TripsLayer + 현재 위치 IconLayer + if (candidateTripsData.length > 0) { + // 후보 선박 궤적 fade trail (emerald) + replayLayers.push(createTripsLayer( + 'replay-candidate-trails', + candidateTripsData, + relativeTime, + 3_600_000, + )); + + // 후보 선박 현재 위치 (개별 타임라인 보간) + const candPositions = interpolateFromTripsData( + candidateTripsData, + new Map([...candidateMetadata].map(([k, v]) => [k, { name: v.name, role: 'CANDIDATE', isParent: false }])), + relativeTime, + memberCursorsRef.current, + ); + if (candPositions.length > 0) { + replayLayers.push(new IconLayer({ + id: 'replay-candidate-ships', + data: candPositions, + pickable: true, + iconAtlas: SHIP_URI, + iconMapping: { + ship: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true }, + }, + getIcon: () => 'ship', + getPosition: (d: MemberPosition) => [d.lon, d.lat], + getSize: 22, + getAngle: (d: MemberPosition) => -(d.cog ?? 0), + getColor: [16, 185, 129, 220] as [number, number, number, number], // emerald + sizeUnits: 'pixels' as const, + sizeMinPixels: 10, + billboard: false, + })); + + // 후보 선박 라벨 + replayLayers.push(new TextLayer({ + id: 'replay-candidate-labels', + data: candPositions, + getPosition: (d: MemberPosition) => [d.lon, d.lat], + getText: (d: MemberPosition) => { + const meta = candidateMetadata.get(d.mmsi); + return meta?.name || d.mmsi; + }, + getColor: [16, 185, 129, 220], + getSize: 10, + getPixelOffset: [0, -18], + fontFamily: 'monospace', + fontWeight: 'bold' as const, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + sizeUnits: 'pixels' as const, + sizeMinPixels: 8, + sizeMaxPixels: 12, + billboard: true, + })); + } + } + + // iran 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출 + const baseLayers = baseLayersRef.current(); + overlay.setProps({ layers: [...baseLayers, ...replayLayers] }); + }, [overlayRef]); + + /** + * currentTime 구독 — iran useReplayLayer.ts:425~458 그대로 적용 + * + * 핵심: 재생 중 쓰로틀에 걸려도 pendingRafId로 다음 rAF에 반드시 렌더 예약 + * → 프레임 드롭 없이 부드러운 애니메이션 + * + * 가드 없이 항상 구독 — renderFrame 내부에서 historyFrames.length===0이면 baseLayers만 표시 + */ + useEffect(() => { + let lastRenderTime = 0; + let pendingRafId: number | null = null; + + const unsub = useGearReplayStore.subscribe( + (s) => s.currentTime, + () => { + // 데이터 없으면 무시 + if (!useGearReplayStore.getState().groupKey) return; + + const isPlaying = useGearReplayStore.getState().isPlaying; + // seek/정지: 즉시 렌더 (iran:437~439) + if (!isPlaying) { + renderFrame(); + return; + } + // 재생 중: 쓰로틀 + pending rAF (iran:441~451) + const now = performance.now(); + if (now - lastRenderTime >= RENDER_INTERVAL_MS) { + lastRenderTime = now; + renderFrame(); + } else if (!pendingRafId) { + pendingRafId = requestAnimationFrame(() => { + pendingRafId = null; + lastRenderTime = performance.now(); + renderFrame(); + }); + } + }, + ); + return () => { + unsub(); + if (pendingRafId) cancelAnimationFrame(pendingRafId); + }; + }, [renderFrame]); + + // groupKey 변경 구독 + useEffect(() => { + const unsub = useGearReplayStore.subscribe( + s => s.groupKey, + (groupKey) => { + if (groupKey) { + frameCursorRef.current = 0; + memberCursorsRef.current.clear(); + renderFrame(); + } else { + // 리플레이 종료 → 기본 레이어 복원 + const overlay = overlayRef.current; + if (overlay) { + overlay.setProps({ layers: baseLayersRef.current() }); + } + } + }, + ); + return unsub; + }, [renderFrame, overlayRef]); +} diff --git a/frontend/src/lib/map/BaseMap.tsx b/frontend/src/lib/map/BaseMap.tsx index 241c55d..5278f49 100644 --- a/frontend/src/lib/map/BaseMap.tsx +++ b/frontend/src/lib/map/BaseMap.tsx @@ -70,6 +70,7 @@ export const BaseMap = memo(forwardRef(function BaseMap // overlay를 외부에 노출 — useMapLayers hook에서 직접 접근 useImperativeHandle(ref, () => ({ get overlay() { return overlayRef.current; }, + get map() { return mapRef.current; }, }), []); // 지도 초기화 (1회) diff --git a/frontend/src/lib/map/data/fishing-zones-wgs84.json b/frontend/src/lib/map/data/fishing-zones-wgs84.json new file mode 100644 index 0000000..937a910 --- /dev/null +++ b/frontend/src/lib/map/data/fishing-zones-wgs84.json @@ -0,0 +1 @@ +{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"id": "ZONE_I", "name_ko": "특정해역 I (동해)", "name_en": "Zone I (East Sea)", "color": "#8b5cf6", "name": "특정어업수역Ⅰ"}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[131.265, 36.1666], [130.7116, 35.705], [130.656608, 35.65], [129.716195, 35.65], [129.719062, 35.663246], [129.721805, 35.679265], [129.72751, 35.691992], [129.735849, 35.718435], [129.738702, 35.732259], [129.744407, 35.74367], [129.75121, 35.7632], [129.756805, 35.779329], [129.765363, 35.795567], [129.775019, 35.819486], [129.780614, 35.843624], [129.783577, 35.874565], [129.783577, 35.892998], [129.78632, 35.901007], [129.800254, 35.920537], [129.818467, 35.958391], [129.829768, 35.991635], [129.829768, 35.993062], [129.832292, 36.004911], [129.831743, 36.023564], [129.832292, 36.04441], [129.830646, 36.068], [129.826587, 36.090163], [129.818138, 36.11101], [129.808044, 36.139866], [129.794439, 36.168393], [129.781163, 36.186168], [129.767448, 36.202187], [129.745175, 36.223253], [129.720817, 36.241686], [129.702055, 36.253645], [129.67671, 36.266702], [129.673723, 36.268056], [129.660252, 36.274162], [129.643794, 36.279539], [129.629641, 36.283708], [129.629421, 36.290511], [129.648732, 36.315527], [129.659813, 36.336702], [129.665519, 36.347784], [129.675394, 36.37291], [129.682306, 36.396938], [129.685049, 36.424258], [129.686475, 36.440168], [129.690645, 36.453883], [129.699093, 36.483397], [129.701946, 36.514118], [129.70041, 36.540232], [129.691742, 36.570843], [129.687572, 36.586862], [129.691742, 36.59937], [129.707212, 36.630092], [129.71972, 36.66191], [129.724109, 36.689121], [129.725426, 36.711723], [129.726742, 36.743541], [129.728168, 36.786771], [129.72367, 36.819467], [129.716648, 36.838668], [129.706773, 36.857978], [129.695362, 36.881787], [129.681209, 36.910095], [129.67287, 36.925785], [129.671334, 36.936098], [129.671334, 36.954092], [129.667055, 36.974391], [129.668372, 36.987996], [129.668372, 37.001491], [129.671114, 37.009501], [129.67671, 37.029908], [129.679563, 37.0682], [129.675174, 37.101006], [129.666726, 37.128107], [129.658279, 37.145001], [129.658277, 37.145004], [129.649719, 37.158499], [129.628105, 37.184393], [129.618559, 37.193171], [129.621302, 37.208751], [129.622289, 37.233218], [129.622289, 37.256149], [129.617572, 37.279959], [129.611976, 37.296965], [129.603747, 37.313971], [129.595299, 37.33021], [129.575988, 37.351605], [129.561176, 37.367953], [129.54801, 37.379035], [129.538793, 37.38869], [129.524859, 37.403393], [129.499843, 37.427092], [129.49326, 37.44333], [129.476802, 37.474381], [129.461112, 37.495886], [129.438729, 37.516403], [129.424795, 37.528253], [129.408995, 37.551843], [129.395061, 37.571153], [129.379371, 37.583661], [129.36818, 37.593207], [129.367851, 37.595511], [129.363462, 37.626562], [129.349637, 37.653772], [129.338446, 37.674399], [129.325499, 37.69349], [129.307834, 37.710497], [129.303226, 37.726625], [129.296753, 37.743632], [129.282818, 37.767112], [129.267128, 37.788397], [129.249573, 37.806172], [129.235529, 37.819338], [129.219839, 37.828994], [129.214244, 37.831846], [129.203052, 37.851705], [129.19592, 37.858344], [129.176171, 37.876721], [129.159713, 37.889888], [129.139196, 37.901628], [129.116923, 37.931032], [129.095527, 37.950892], [129.079838, 37.962522], [129.069743, 37.971299], [129.061185, 37.991049], [129.048238, 38.008055], [129.035401, 38.025062], [129.021357, 38.041081], [128.999304, 38.053479], [128.993598, 38.067962], [128.977908, 38.087053], [128.970448, 38.096379], [128.956733, 38.121395], [128.93808, 38.143339], [128.920416, 38.160236], [128.913174, 38.166051], [128.899788, 38.17417], [128.893534, 38.182838], [128.881356, 38.198967], [128.865666, 38.216522], [128.857437, 38.250864], [128.85595, 38.2544], [129.413676, 38.2544], [129.99651, 38.2544], [130.164272, 38.002769], [131.6666, 38.002784], [131.6666, 37.425], [131.632657, 37.326115], [131.632455, 37.325711], [131.631139, 37.323188], [131.629712, 37.320445], [131.628396, 37.317812], [131.628286, 37.317373], [131.627737, 37.316276], [131.626311, 37.313533], [131.625324, 37.310899], [131.624007, 37.308156], [131.6228, 37.305523], [131.621922, 37.30278], [131.620825, 37.300037], [131.619838, 37.297294], [131.61874, 37.294551], [131.617972, 37.291589], [131.617204, 37.288956], [131.616217, 37.286103], [131.615559, 37.28336], [131.6149, 37.280507], [131.614242, 37.277764], [131.613803, 37.274911], [131.613254, 37.272059], [131.612706, 37.269316], [131.612157, 37.266463], [131.612145, 37.266296], [131.5666, 37.1333], [131.427342, 37.040556], [131.1666, 36.8666], [130.375, 36.8666], [130.375, 36.1666], [131.265, 36.1666]], [[130.54053, 37.533959], [130.54042, 37.532532], [130.54031, 37.530996], [130.54031, 37.529679], [130.540091, 37.528143], [130.539871, 37.5254], [130.539871, 37.523974], [130.539871, 37.522438], [130.539762, 37.521231], [130.539762, 37.519695], [130.539762, 37.518159], [130.539762, 37.516842], [130.539762, 37.515416], [130.539871, 37.51388], [130.539871, 37.512563], [130.539871, 37.511027], [130.540091, 37.508284], [130.54031, 37.506858], [130.54031, 37.505432], [130.54042, 37.504005], [130.54053, 37.502579], [130.540859, 37.501152], [130.540968, 37.499726], [130.541188, 37.496983], [130.541627, 37.49413], [130.542175, 37.491278], [130.542614, 37.488425], [130.543053, 37.485682], [130.543821, 37.482829], [130.544479, 37.479977], [130.545138, 37.477124], [130.545796, 37.474381], [130.546674, 37.471638], [130.547552, 37.468895], [130.548429, 37.466152], [130.549307, 37.463409], [130.550404, 37.460556], [130.550514, 37.460337], [130.550733, 37.458691], [130.551172, 37.455838], [130.551721, 37.452986], [130.552489, 37.450133], [130.552928, 37.44739], [130.553696, 37.444537], [130.554464, 37.441904], [130.555122, 37.438942], [130.556, 37.436199], [130.556768, 37.433456], [130.556987, 37.433017], [130.557865, 37.430713], [130.558633, 37.42797], [130.55973, 37.425227], [130.560828, 37.422484], [130.561925, 37.419741], [130.563132, 37.417108], [130.564229, 37.414365], [130.565546, 37.411731], [130.566862, 37.409098], [130.568289, 37.406355], [130.569605, 37.403722], [130.570922, 37.401089], [130.572568, 37.398565], [130.574104, 37.396041], [130.575749, 37.393408], [130.577505, 37.390885], [130.579041, 37.388251], [130.580797, 37.385838], [130.582442, 37.383314], [130.584308, 37.3809], [130.586283, 37.378377], [130.588148, 37.376073], [130.590123, 37.373549], [130.591878, 37.371245], [130.593963, 37.368831], [130.596267, 37.366527], [130.598242, 37.364223], [130.600436, 37.361809], [130.602631, 37.359615], [130.604715, 37.35742], [130.607239, 37.355116], [130.609324, 37.353032], [130.611847, 37.350837], [130.614151, 37.348643], [130.614919, 37.347984], [130.615139, 37.347765], [130.617443, 37.34568], [130.619637, 37.343376], [130.622051, 37.341182], [130.624684, 37.339207], [130.626988, 37.337013], [130.629402, 37.335038], [130.631816, 37.332953], [130.63434, 37.330868], [130.637083, 37.328893], [130.639606, 37.326918], [130.642349, 37.325053], [130.644982, 37.323188], [130.647725, 37.321213], [130.650468, 37.319348], [130.653211, 37.317592], [130.656064, 37.315837], [130.658807, 37.314081], [130.661879, 37.312435], [130.664622, 37.31079], [130.667475, 37.308924], [130.670657, 37.307279], [130.673509, 37.305852], [130.676472, 37.304316], [130.679544, 37.30278], [130.682506, 37.301244], [130.685798, 37.299927], [130.68887, 37.298501], [130.691942, 37.297075], [130.695234, 37.295758], [130.698525, 37.294332], [130.701707, 37.293125], [130.704779, 37.291918], [130.708071, 37.290821], [130.710485, 37.289943], [130.712569, 37.289175], [130.715641, 37.287749], [130.718933, 37.286432], [130.722115, 37.285115], [130.725406, 37.284018], [130.728479, 37.282811], [130.73188, 37.281604], [130.735172, 37.280507], [130.738463, 37.27941], [130.741864, 37.278422], [130.745266, 37.277325], [130.748667, 37.276448], [130.752068, 37.27546], [130.75536, 37.274692], [130.758761, 37.273705], [130.762162, 37.273046], [130.763369, 37.272498], [130.766661, 37.271071], [130.769733, 37.269974], [130.773134, 37.268877], [130.776316, 37.26767], [130.779718, 37.266573], [130.783009, 37.265476], [130.78641, 37.264378], [130.789702, 37.263391], [130.793103, 37.262513], [130.796505, 37.261635], [130.799906, 37.260648], [130.803307, 37.25977], [130.806818, 37.259002], [130.810219, 37.258124], [130.81373, 37.257466], [130.817022, 37.256808], [130.820753, 37.256149], [130.824264, 37.255711], [130.827665, 37.255162], [130.831176, 37.254613], [130.834687, 37.254065], [130.838308, 37.253626], [130.841819, 37.253187], [130.84533, 37.252748], [130.847195, 37.252638], [130.848841, 37.252529], [130.852461, 37.25209], [130.854217, 37.25209], [130.855972, 37.25198], [130.857947, 37.25187], [130.859703, 37.251651], [130.861458, 37.251651], [130.863324, 37.251541], [130.864969, 37.251541], [130.866835, 37.251432], [130.86848, 37.251432], [130.870346, 37.251322], [130.872211, 37.251322], [130.873857, 37.251322], [130.875722, 37.251322], [130.877477, 37.251322], [130.877697, 37.251322], [130.879233, 37.251322], [130.881208, 37.251322], [130.882963, 37.251432], [130.884719, 37.251432], [130.886474, 37.251432], [130.88834, 37.251541], [130.890095, 37.251541], [130.891851, 37.251651], [130.893716, 37.25187], [130.895362, 37.25198], [130.897227, 37.25198], [130.899092, 37.25209], [130.902713, 37.252529], [130.904249, 37.252638], [130.906224, 37.252748], [130.909735, 37.253187], [130.913356, 37.253626], [130.916867, 37.254065], [130.920378, 37.254613], [130.923779, 37.255162], [130.92729, 37.255711], [130.930911, 37.256149], [130.934422, 37.256808], [130.937823, 37.257466], [130.941334, 37.258124], [130.944735, 37.259002], [130.948137, 37.25977], [130.951648, 37.260648], [130.954939, 37.261635], [130.95834, 37.262513], [130.961742, 37.263391], [130.965143, 37.264378], [130.968325, 37.265476], [130.971726, 37.266573], [130.975127, 37.26767], [130.978419, 37.268877], [130.981601, 37.269974], [130.985002, 37.271071], [130.988074, 37.272498], [130.991256, 37.273705], [130.994438, 37.275131], [130.99762, 37.276448], [131.000692, 37.277874], [131.003984, 37.279191], [131.006946, 37.280727], [131.010018, 37.282372], [131.012981, 37.283908], [131.015833, 37.285444], [131.018905, 37.28709], [131.021758, 37.288736], [131.024611, 37.290382], [131.027573, 37.292028], [131.030426, 37.293783], [131.033279, 37.295648], [131.036022, 37.297404], [131.038655, 37.299159], [131.041508, 37.301134], [131.044141, 37.303], [131.046774, 37.304975], [131.049407, 37.306949], [131.052041, 37.308924], [131.054674, 37.311009], [131.057088, 37.312984], [131.059502, 37.315069], [131.061806, 37.317263], [131.064329, 37.319238], [131.065865, 37.320664], [131.067182, 37.321542], [131.070144, 37.323188], [131.072997, 37.324943], [131.07585, 37.326589], [131.078593, 37.328345], [131.081445, 37.33021], [131.084079, 37.332075], [131.086822, 37.33394], [131.089565, 37.335806], [131.092198, 37.337781], [131.094831, 37.339756], [131.097355, 37.34173], [131.099988, 37.343815], [131.102402, 37.3459], [131.104925, 37.347765], [131.107339, 37.349959], [131.109643, 37.352154], [131.112167, 37.354238], [131.114361, 37.356433], [131.116556, 37.358627], [131.118969, 37.360931], [131.121164, 37.363235], [131.123248, 37.36554], [131.125333, 37.367734], [131.127418, 37.370148], [131.129393, 37.372452], [131.131477, 37.374756], [131.133343, 37.377279], [131.135427, 37.379584], [131.137292, 37.382107], [131.138938, 37.384521], [131.140803, 37.387044], [131.142559, 37.389568], [131.144205, 37.392092], [131.14596, 37.394615], [131.147387, 37.397248], [131.148923, 37.399772], [131.150349, 37.402295], [131.151995, 37.405038], [131.153311, 37.407562], [131.154738, 37.410305], [131.156054, 37.412938], [131.157152, 37.415681], [131.158359, 37.418314], [131.159565, 37.421057], [131.160772, 37.4238], [131.16176, 37.426434], [131.162857, 37.429177], [131.163735, 37.43192], [131.164832, 37.434663], [131.16571, 37.437515], [131.166478, 37.440258], [131.167136, 37.443111], [131.167246, 37.443989], [131.168892, 37.446293], [131.170208, 37.448926], [131.171744, 37.451559], [131.173061, 37.454083], [131.174487, 37.456716], [131.175804, 37.459459], [131.177011, 37.461983], [131.178108, 37.464726], [131.179315, 37.467469], [131.180412, 37.470212], [131.181509, 37.472955], [131.182606, 37.475588], [131.183375, 37.47855], [131.184472, 37.481184], [131.18524, 37.484036], [131.185898, 37.48667], [131.186776, 37.489522], [131.187434, 37.492265], [131.188092, 37.495008], [131.188751, 37.497971], [131.189299, 37.500714], [131.189848, 37.503566], [131.190287, 37.506309], [131.190726, 37.509272], [131.190945, 37.512015], [131.191274, 37.513551], [131.191384, 37.514867], [131.191494, 37.516294], [131.191494, 37.51783], [131.191603, 37.519146], [131.191933, 37.520683], [131.191933, 37.523425], [131.192042, 37.524852], [131.192042, 37.526278], [131.192042, 37.527595], [131.192042, 37.529131], [131.192042, 37.530557], [131.192042, 37.531984], [131.192042, 37.53341], [131.192042, 37.534836], [131.192042, 37.536263], [131.191933, 37.537799], [131.191933, 37.540542], [131.191603, 37.541858], [131.191494, 37.543394], [131.191494, 37.544821], [131.191384, 37.546137], [131.191274, 37.547673], [131.190945, 37.5491], [131.190726, 37.551843], [131.190287, 37.554695], [131.189848, 37.557438], [131.189299, 37.560401], [131.188751, 37.563144], [131.188092, 37.565997], [131.187434, 37.56874], [131.186776, 37.571702], [131.185898, 37.574335], [131.18524, 37.577188], [131.184472, 37.579821], [131.183375, 37.582674], [131.182606, 37.585307], [131.181509, 37.58816], [131.180412, 37.590793], [131.179315, 37.593536], [131.178108, 37.596169], [131.177011, 37.598912], [131.175804, 37.601546], [131.174487, 37.604289], [131.173061, 37.606922], [131.171744, 37.609555], [131.170208, 37.612188], [131.168892, 37.614822], [131.167246, 37.617455], [131.16571, 37.619869], [131.164174, 37.622502], [131.162418, 37.624916], [131.160772, 37.627439], [131.158907, 37.629853], [131.157152, 37.632486], [131.155396, 37.634791], [131.153531, 37.637314], [131.151446, 37.639618], [131.149581, 37.641922], [131.147496, 37.644446], [131.145521, 37.64664], [131.143327, 37.648944], [131.141352, 37.651248], [131.139158, 37.653552], [131.136854, 37.655747], [131.134549, 37.657941], [131.132355, 37.660136], [131.129941, 37.66233], [131.127637, 37.664415], [131.125223, 37.666499], [131.1227, 37.668584], [131.120176, 37.670778], [131.117653, 37.672753], [131.115129, 37.674728], [131.11491, 37.675057], [131.113593, 37.676155], [131.112496, 37.677142], [131.110192, 37.679337], [131.107668, 37.681421], [131.105364, 37.683506], [131.102731, 37.685481], [131.100207, 37.687456], [131.097684, 37.68954], [131.09516, 37.691406], [131.092527, 37.693381], [131.089784, 37.695356], [131.08726, 37.697221], [131.084408, 37.699086], [131.081665, 37.700841], [131.078702, 37.702597], [131.075959, 37.704243], [131.073216, 37.706108], [131.070364, 37.707864], [131.067511, 37.7094], [131.064329, 37.711045], [131.061367, 37.712691], [131.058404, 37.714117], [131.055442, 37.715544], [131.05237, 37.71708], [131.049188, 37.718506], [131.046116, 37.720042], [131.043153, 37.721359], [131.039862, 37.722785], [131.03668, 37.723992], [131.033388, 37.725199], [131.030316, 37.726516], [131.027025, 37.727723], [131.023733, 37.72882], [131.020441, 37.729917], [131.01704, 37.731014], [131.013858, 37.732111], [131.013419, 37.732221], [131.012871, 37.732441], [131.009579, 37.733538], [131.006397, 37.734745], [131.002996, 37.735842], [130.999924, 37.736939], [130.996523, 37.738036], [130.993121, 37.738914], [130.98972, 37.740011], [130.986319, 37.740999], [130.983027, 37.741767], [130.979626, 37.742754], [130.976225, 37.743522], [130.972604, 37.74429], [130.969203, 37.744949], [130.967118, 37.745387], [130.965692, 37.745607], [130.96229, 37.746375], [130.958779, 37.747033], [130.955268, 37.747582], [130.951867, 37.74813], [130.948246, 37.748679], [130.944735, 37.749008], [130.941224, 37.749557], [130.937713, 37.749776], [130.935738, 37.750105], [130.933983, 37.750215], [130.930472, 37.750435], [130.926961, 37.750764], [130.925095, 37.750873], [130.92345, 37.750983], [130.921584, 37.750983], [130.919719, 37.751203], [130.918073, 37.751312], [130.916208, 37.751312], [130.914453, 37.751422], [130.912697, 37.751422], [130.910942, 37.751422], [130.908967, 37.751422], [130.907211, 37.751422], [130.905456, 37.751532], [130.903591, 37.751532], [130.901945, 37.751422], [130.900079, 37.751422], [130.898214, 37.751422], [130.896568, 37.751422], [130.894703, 37.751312], [130.893057, 37.751312], [130.891192, 37.751203], [130.889437, 37.751203], [130.887681, 37.750983], [130.885706, 37.750873], [130.883951, 37.750764], [130.88044, 37.750435], [130.876929, 37.750215], [130.875064, 37.750105], [130.873418, 37.749776], [130.869797, 37.749557], [130.866286, 37.749008], [130.862775, 37.748679], [130.859264, 37.74813], [130.855643, 37.747582], [130.852132, 37.747033], [130.848731, 37.746375], [130.84522, 37.745607], [130.841819, 37.744949], [130.838308, 37.74429], [130.834906, 37.743522], [130.831505, 37.742754], [130.827884, 37.741767], [130.824483, 37.740999], [130.821082, 37.740011], [130.818119, 37.739682], [130.816254, 37.739463], [130.814389, 37.739353], [130.810987, 37.738914], [130.807476, 37.738585], [130.803965, 37.738146], [130.800454, 37.737597], [130.796834, 37.737049], [130.793213, 37.7365], [130.789921, 37.735842], [130.78641, 37.735184], [130.783009, 37.734525], [130.779498, 37.733648], [130.776097, 37.732989], [130.772476, 37.732111], [130.769075, 37.731343], [130.765673, 37.730356], [130.762272, 37.729368], [130.758871, 37.728491], [130.755579, 37.727613], [130.752397, 37.726516], [130.748996, 37.725419], [130.745814, 37.724102], [130.742413, 37.723114], [130.739231, 37.721908], [130.73594, 37.720701], [130.732867, 37.719274], [130.729576, 37.718177], [130.726504, 37.71686], [130.725077, 37.716312], [130.721786, 37.715434], [130.718384, 37.714666], [130.717287, 37.714337], [130.714983, 37.713788], [130.711582, 37.712911], [130.708181, 37.712033], [130.704779, 37.711045], [130.701488, 37.709948], [130.698086, 37.708851], [130.695014, 37.707864], [130.691613, 37.706766], [130.688321, 37.705559], [130.68514, 37.704243], [130.681848, 37.703036], [130.678776, 37.701939], [130.675484, 37.700512], [130.672193, 37.699196], [130.66923, 37.697769], [130.666048, 37.696453], [130.662867, 37.695026], [130.659904, 37.69349], [130.656832, 37.691954], [130.65387, 37.690528], [130.651017, 37.688882], [130.647945, 37.687346], [130.645092, 37.6857], [130.642239, 37.683835], [130.639277, 37.682189], [130.636424, 37.680543], [130.633681, 37.678678], [130.631048, 37.676813], [130.628195, 37.675057], [130.625452, 37.673083], [130.622929, 37.671327], [130.620186, 37.669352], [130.617662, 37.667377], [130.615139, 37.665402], [130.612615, 37.663317], [130.610092, 37.661233], [130.607787, 37.659368], [130.605154, 37.657173], [130.60274, 37.655089], [130.600436, 37.652894], [130.598242, 37.6507], [130.595938, 37.648396], [130.593634, 37.646201], [130.591659, 37.644007], [130.589464, 37.641703], [130.587489, 37.639508], [130.585185, 37.636985], [130.583101, 37.634681], [130.581345, 37.632267], [130.57937, 37.629853], [130.577505, 37.627439], [130.57553, 37.625025], [130.573884, 37.622612], [130.572019, 37.620198], [130.570264, 37.617674], [130.568618, 37.615041], [130.567191, 37.612517], [130.565546, 37.609994], [130.564119, 37.607361], [130.562583, 37.604837], [130.561047, 37.602094], [130.55973, 37.59968], [130.558414, 37.596937], [130.557207, 37.594414], [130.555781, 37.591671], [130.554574, 37.589038], [130.553367, 37.586295], [130.552489, 37.583661], [130.551392, 37.580809], [130.550404, 37.578175], [130.549307, 37.575432], [130.548539, 37.57258], [130.547661, 37.569837], [130.546893, 37.567094], [130.545906, 37.564241], [130.545248, 37.561498], [130.544589, 37.558755], [130.54415, 37.555902], [130.543492, 37.55305], [130.542943, 37.550307], [130.542943, 37.549868], [130.542614, 37.548112], [130.542175, 37.54526], [130.541627, 37.542407], [130.541188, 37.539554], [130.540859, 37.535275], [130.54053, 37.533959]]], [[[128.813313, 34.343917], [128.813549, 34.343982], [128.816182, 34.35243], [128.806308, 34.41486], [128.804991, 34.4266], [128.789301, 34.509548], [128.830226, 34.561884], [128.835822, 34.572417], [128.842734, 34.581853], [128.849866, 34.592276], [128.932814, 34.708908], [128.966388, 34.755429], [128.979115, 34.774081], [128.996012, 34.797342], [129.015762, 34.824113], [129.039571, 34.860211], [129.105731, 34.950729], [129.150716, 35.011184], [129.191532, 35.0599], [129.2352, 35.105214], [129.268555, 35.136923], [129.447873, 35.131455], [129.462867, 35.130998], [129.464184, 35.13813], [129.467805, 35.147456], [129.475485, 35.159086], [129.48909, 35.188052], [129.503244, 35.194525], [129.520141, 35.20451], [129.537147, 35.217237], [129.552069, 35.23183], [129.564797, 35.245654], [129.576317, 35.261235], [129.586192, 35.277692], [129.593872, 35.292066], [129.599029, 35.307207], [129.604515, 35.323994], [129.607368, 35.340342], [129.615267, 35.346048], [129.630738, 35.358226], [129.647854, 35.373368], [129.659155, 35.390813], [129.669688, 35.408807], [129.67693, 35.430202], [129.681428, 35.439748], [129.68834, 35.46619], [129.691193, 35.476723], [129.698435, 35.489341], [129.710833, 35.518197], [129.719391, 35.549248], [129.720708, 35.574593], [129.717745, 35.601035], [129.71204, 35.622979], [129.710723, 35.629891], [129.713576, 35.637901], [129.716195, 35.65], [130.656608, 35.65], [130.5683, 35.5616], [130.3883, 35.3033], [130.2733, 35.1166], [130.125, 35.1133], [129.712736, 35.071743], [129.6783, 35.0683], [129.5483, 35.02], [129.3766, 34.96], [129.3066, 34.905], [129.2633, 34.8733], [129.2166, 34.8433], [129.0516, 34.6716], [129.0133, 34.5433], [129.0133, 34.535], [129.0033, 34.4866], [128.99, 34.46], [128.8883, 34.3083], [128.887965, 34.307977], [128.813313, 34.343917]]]]}}, {"type": "Feature", "properties": {"id": "ZONE_II", "name_ko": "특정해역 II (남해)", "name_en": "Zone II (South Sea)", "color": "#3b82f6", "name": "특정어업수역Ⅱ"}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[126.000509, 32.1833], [126.000154, 33.128268], [126.000787, 33.127635], [126.00364, 33.124892], [126.006492, 33.122368], [126.007151, 33.12171], [126.007919, 33.119515], [126.008467, 33.11765], [126.009126, 33.116004], [126.009784, 33.114249], [126.010333, 33.112493], [126.01121, 33.110848], [126.011869, 33.109092], [126.012527, 33.107446], [126.013185, 33.105691], [126.014063, 33.104045], [126.014831, 33.102399], [126.015599, 33.100753], [126.016477, 33.098998], [126.017245, 33.097242], [126.018123, 33.095706], [126.019, 33.093951], [126.019878, 33.092415], [126.020756, 33.090659], [126.021634, 33.089123], [126.022511, 33.087477], [126.023609, 33.085941], [126.024596, 33.084186], [126.025474, 33.08265], [126.026461, 33.081004], [126.027559, 33.079468], [126.028546, 33.077822], [126.029533, 33.076176], [126.030631, 33.07464], [126.03107, 33.073104], [126.031508, 33.071568], [126.032057, 33.069813], [126.032386, 33.068057], [126.032935, 33.066302], [126.033483, 33.064546], [126.034032, 33.062791], [126.034581, 33.060925], [126.035239, 33.059279], [126.035787, 33.057524], [126.036446, 33.055878], [126.037104, 33.054013], [126.037653, 33.052367], [126.038311, 33.050612], [126.039079, 33.048856], [126.039847, 33.04721], [126.040505, 33.045455], [126.041383, 33.043809], [126.042041, 33.042054], [126.04281, 33.040408], [126.043687, 33.038762], [126.044455, 33.037006], [126.045223, 33.035361], [126.046211, 33.033715], [126.046979, 33.032069], [126.047966, 33.030423], [126.048954, 33.028887], [126.049722, 33.027132], [126.050819, 33.025596], [126.051697, 33.02384], [126.052684, 33.022304], [126.053672, 33.020658], [126.054769, 33.019013], [126.055647, 33.017476], [126.056744, 33.015831], [126.057841, 33.014404], [126.058938, 33.012759], [126.060035, 33.011222], [126.061133, 33.009686], [126.06234, 33.00815], [126.063546, 33.006614], [126.064644, 33.005078], [126.065851, 33.003542], [126.067057, 33.002116], [126.069471, 32.999153], [126.071885, 32.996191], [126.074628, 32.993229], [126.077152, 32.990376], [126.079895, 32.987523], [126.082528, 32.98478], [126.085381, 32.982037], [126.088233, 32.979294], [126.091196, 32.976551], [126.094158, 32.974028], [126.09535, 32.973041], [126.09734, 32.971394], [126.100302, 32.968871], [126.103594, 32.966567], [126.106776, 32.964043], [126.107105, 32.963824], [126.107434, 32.963495], [126.110616, 32.961081], [126.113798, 32.958667], [126.117199, 32.956363], [126.1206, 32.954059], [126.124002, 32.951974], [126.127293, 32.94978], [126.130804, 32.947585], [126.134535, 32.945501], [126.138046, 32.943526], [126.139801, 32.942538], [126.141667, 32.941551], [126.143422, 32.940673], [126.145287, 32.939686], [126.147043, 32.938808], [126.149018, 32.93782], [126.150883, 32.937052], [126.152748, 32.936065], [126.154613, 32.935187], [126.156588, 32.934419], [126.158454, 32.933432], [126.160319, 32.932664], [126.162184, 32.931895], [126.164269, 32.931018], [126.166134, 32.93025], [126.167999, 32.929482], [126.170084, 32.928823], [126.171949, 32.927946], [126.174034, 32.927287], [126.176009, 32.926629], [126.177874, 32.925971], [126.179959, 32.925203], [126.181933, 32.924544], [126.184018, 32.923886], [126.185993, 32.923337], [126.188078, 32.922679], [126.190053, 32.922021], [126.192137, 32.921472], [126.194112, 32.920924], [126.196197, 32.920375], [126.198172, 32.919826], [126.200366, 32.919168], [126.202341, 32.918729], [126.204426, 32.918181], [126.206401, 32.917742], [126.208595, 32.917303], [126.21068, 32.916974], [126.212765, 32.916425], [126.214849, 32.915986], [126.217044, 32.915547], [126.219019, 32.915218], [126.221213, 32.914889], [126.223188, 32.91456], [126.225382, 32.914231], [126.227577, 32.913902], [126.229552, 32.913572], [126.231746, 32.913243], [126.23394, 32.913024], [126.236025, 32.912695], [126.238219, 32.912475], [126.240194, 32.912365], [126.242389, 32.912146], [126.244583, 32.911927], [126.246778, 32.911817], [126.248753, 32.911597], [126.251057, 32.911488], [126.253141, 32.911378], [126.255226, 32.911268], [126.25742, 32.911159], [126.259615, 32.911049], [126.261699, 32.911049], [126.263894, 32.911049], [126.266088, 32.911049], [126.268283, 32.910939], [126.270367, 32.910939], [126.272452, 32.911049], [126.274646, 32.911049], [126.276731, 32.911049], [126.278925, 32.911159], [126.28112, 32.911268], [126.283204, 32.911378], [126.285399, 32.911488], [126.287593, 32.911597], [126.289787, 32.911817], [126.291762, 32.911927], [126.293957, 32.912146], [126.296041, 32.912256], [126.298236, 32.912475], [126.30043, 32.912695], [126.302405, 32.913024], [126.3046, 32.913243], [126.306794, 32.913572], [126.308988, 32.913902], [126.310963, 32.914231], [126.313158, 32.91456], [126.315242, 32.914889], [126.317327, 32.915218], [126.319521, 32.915547], [126.321496, 32.915986], [126.323691, 32.916425], [126.325666, 32.916974], [126.32786, 32.917303], [126.329945, 32.917742], [126.33192, 32.918181], [126.334114, 32.918729], [126.336089, 32.919168], [126.338174, 32.919826], [126.340149, 32.920375], [126.342233, 32.920924], [126.344318, 32.921472], [126.346403, 32.922021], [126.348378, 32.922679], [126.350462, 32.923337], [126.352437, 32.923886], [126.354412, 32.924544], [126.356387, 32.925203], [126.358472, 32.925971], [126.360447, 32.926629], [126.362312, 32.927287], [126.364397, 32.927946], [126.366372, 32.928823], [126.368346, 32.929482], [126.370212, 32.93025], [126.372187, 32.931018], [126.374162, 32.931895], [126.376027, 32.932664], [126.377892, 32.933432], [126.379977, 32.934419], [126.381732, 32.935187], [126.383597, 32.936065], [126.385463, 32.937052], [126.387328, 32.93782], [126.389303, 32.938808], [126.391058, 32.939686], [126.392924, 32.940673], [126.394789, 32.941551], [126.396544, 32.942538], [126.39841, 32.943526], [126.401921, 32.945501], [126.405541, 32.947585], [126.409052, 32.94978], [126.412344, 32.951974], [126.415855, 32.954059], [126.419146, 32.956363], [126.422548, 32.958667], [126.42573, 32.961081], [126.429021, 32.963495], [126.432093, 32.965908], [126.435275, 32.968432], [126.438347, 32.971065], [126.4412, 32.973698], [126.444162, 32.976332], [126.447125, 32.979075], [126.449868, 32.981818], [126.452721, 32.984561], [126.455244, 32.987413], [126.457987, 32.990266], [126.460511, 32.993229], [126.463144, 32.996191], [126.465558, 32.999153], [126.467862, 33.002116], [126.469069, 33.003652], [126.470166, 33.005188], [126.471373, 33.006724], [126.47247, 33.00826], [126.473457, 33.009796], [126.474555, 33.011332], [126.475652, 33.012868], [126.476749, 33.014514], [126.478614, 33.017476], [126.480699, 33.017257], [126.482784, 33.016928], [126.484978, 33.016708], [126.487063, 33.016489], [126.489147, 33.01616], [126.491342, 33.01594], [126.493536, 33.015831], [126.495511, 33.015611], [126.497815, 33.015502], [126.4999, 33.015282], [126.501984, 33.015172], [126.504179, 33.015063], [126.506373, 33.015063], [126.508348, 33.014953], [126.510652, 33.014953], [126.512737, 33.014843], [126.514931, 33.014843], [126.517016, 33.014843], [126.51921, 33.014843], [126.521405, 33.014843], [126.523489, 33.014953], [126.525684, 33.014953], [126.527878, 33.015063], [126.530073, 33.015063], [126.532048, 33.015172], [126.534242, 33.015282], [126.536436, 33.015502], [126.538521, 33.015611], [126.540715, 33.015831], [126.54291, 33.01594], [126.544885, 33.01616], [126.547079, 33.016489], [126.549273, 33.016708], [126.551248, 33.016928], [126.553443, 33.017257], [126.555637, 33.017476], [126.557722, 33.017806], [126.559806, 33.018025], [126.561891, 33.018354], [126.563976, 33.018683], [126.56617, 33.019013], [126.568145, 33.019561], [126.57034, 33.01989], [126.572314, 33.020329], [126.574509, 33.020768], [126.576594, 33.021207], [126.578678, 33.021536], [126.580763, 33.022085], [126.582738, 33.022633], [126.584932, 33.023182], [126.586907, 33.023621], [126.588992, 33.024169], [126.590967, 33.024718], [126.593051, 33.025376], [126.595026, 33.025925], [126.595904, 33.026144], [126.596124, 33.026035], [126.598318, 33.025596], [126.600293, 33.025047], [126.602378, 33.024608], [126.604572, 33.024279], [126.606547, 33.02384], [126.608741, 33.023511], [126.610936, 33.023182], [126.612911, 33.022853], [126.615105, 33.022524], [126.61719, 33.022085], [126.619274, 33.021865], [126.621469, 33.021536], [126.623553, 33.021317], [126.625638, 33.020987], [126.627832, 33.020768], [126.629917, 33.020549], [126.632111, 33.020439], [126.634306, 33.020219], [126.636281, 33.02011], [126.638475, 33.01989], [126.64067, 33.019781], [126.642754, 33.019671], [126.644949, 33.019561], [126.647143, 33.019451], [126.649118, 33.019342], [126.651312, 33.019342], [126.653507, 33.019342], [126.655701, 33.019122], [126.657786, 33.019122], [126.65998, 33.019122], [126.662175, 33.019342], [126.664259, 33.019342], [126.666454, 33.019342], [126.668648, 33.019451], [126.670623, 33.019561], [126.672817, 33.019671], [126.675012, 33.019781], [126.677096, 33.01989], [126.679291, 33.02011], [126.681485, 33.020219], [126.68346, 33.020439], [126.685654, 33.020549], [126.687849, 33.020768], [126.689824, 33.020987], [126.692018, 33.021317], [126.694213, 33.021536], [126.696297, 33.021865], [126.698492, 33.022085], [126.700576, 33.022524], [126.702661, 33.022853], [126.704746, 33.023182], [126.70694, 33.023511], [126.708915, 33.02384], [126.711109, 33.024279], [126.713084, 33.024608], [126.715279, 33.025047], [126.717254, 33.025596], [126.719448, 33.026035], [126.721533, 33.026473], [126.723508, 33.027022], [126.725702, 33.027461], [126.727677, 33.02801], [126.729762, 33.028668], [126.731737, 33.029107], [126.733821, 33.029655], [126.735796, 33.030204], [126.737881, 33.030862], [126.739856, 33.031521], [126.74194, 33.032179], [126.743915, 33.032727], [126.746, 33.033386], [126.747865, 33.034044], [126.74995, 33.034812], [126.751925, 33.03547], [126.75379, 33.036129], [126.755875, 33.036897], [126.75774, 33.037775], [126.759715, 33.038433], [126.76169, 33.039201], [126.763555, 33.039969], [126.76542, 33.040847], [126.767505, 33.041615], [126.76937, 33.042492], [126.771235, 33.04337], [126.773101, 33.044248], [126.775076, 33.045126], [126.776831, 33.046003], [126.778696, 33.046991], [126.780562, 33.047869], [126.782317, 33.048746], [126.784292, 33.049734], [126.786048, 33.050612], [126.787913, 33.051599], [126.789668, 33.052696], [126.793179, 33.054671], [126.79669, 33.056756], [126.800201, 33.05895], [126.803603, 33.061035], [126.807004, 33.063339], [126.810295, 33.065643], [126.813587, 33.067947], [126.816879, 33.070361], [126.82006, 33.072775], [126.823023, 33.075189], [126.826205, 33.077712], [126.829167, 33.080346], [126.832239, 33.082979], [126.835092, 33.085722], [126.837945, 33.088245], [126.840797, 33.090988], [126.843431, 33.093841], [126.845076, 33.095597], [126.848039, 33.096694], [126.850124, 33.097462], [126.851989, 33.09834], [126.853854, 33.099108], [126.855939, 33.099876], [126.857804, 33.100863], [126.859669, 33.101631], [126.861534, 33.102509], [126.863509, 33.103387], [126.865375, 33.104374], [126.86713, 33.105252], [126.868995, 33.10613], [126.870751, 33.107117], [126.872726, 33.108105], [126.874481, 33.108982], [126.87514, 33.109311], [126.876895, 33.109531], [126.87898, 33.10997], [126.881064, 33.110299], [126.883259, 33.110628], [126.885234, 33.110957], [126.887428, 33.111396], [126.889403, 33.111725], [126.891597, 33.112164], [126.893792, 33.112603], [126.895767, 33.113152], [126.897851, 33.113591], [126.899826, 33.114029], [126.902021, 33.114468], [126.903996, 33.114907], [126.90608, 33.115456], [126.908055, 33.116114], [126.91025, 33.116663], [126.912334, 33.117211], [126.914309, 33.11776], [126.916394, 33.118308], [126.918369, 33.118857], [126.920454, 33.119625], [126.922429, 33.120283], [126.924294, 33.120942], [126.926378, 33.12149], [126.928353, 33.122368], [126.930328, 33.123026], [126.932303, 33.123685], [126.934388, 33.124343], [126.936253, 33.125221], [126.938118, 33.125989], [126.940203, 33.126757], [126.942068, 33.127525], [126.943933, 33.128403], [126.946018, 33.129171], [126.947883, 33.130048], [126.949749, 33.130816], [126.951614, 33.131804], [126.953589, 33.132572], [126.955344, 33.13345], [126.95721, 33.134437], [126.959075, 33.135315], [126.96083, 33.136302], [126.962695, 33.13718], [126.964451, 33.138168], [126.966426, 33.139155], [126.968181, 33.140143], [126.971692, 33.142227], [126.975203, 33.144312], [126.978714, 33.146287], [126.982116, 33.148591], [126.985407, 33.150785], [126.988809, 33.153089], [126.9921, 33.155394], [126.995282, 33.157807], [126.998574, 33.160221], [127.001646, 33.162745], [127.004828, 33.165268], [127.004937, 33.165488], [127.005815, 33.166036], [127.008997, 33.16834], [127.012179, 33.170864], [127.01547, 33.173278], [127.018433, 33.175911], [127.021505, 33.178435], [127.024358, 33.181068], [127.02732, 33.183701], [127.030173, 33.186444], [127.033026, 33.189187], [127.035768, 33.19204], [127.038402, 33.194783], [127.041035, 33.197745], [127.043449, 33.200598], [127.046082, 33.20356], [127.048496, 33.206523], [127.049703, 33.208059], [127.0508, 33.209595], [127.052007, 33.211021], [127.053104, 33.212667], [127.054311, 33.214203], [127.055408, 33.215739], [127.056396, 33.217275], [127.057493, 33.218921], [127.05848, 33.220457], [127.059578, 33.221993], [127.060675, 33.223529], [127.061553, 33.225285], [127.06254, 33.226821], [127.063637, 33.228467], [127.064515, 33.230003], [127.065502, 33.231648], [127.06649, 33.233294], [127.067258, 33.23494], [127.068245, 33.236586], [127.069013, 33.238232], [127.069781, 33.239877], [127.070769, 33.241633], [127.071537, 33.243279], [127.072415, 33.244924], [127.073073, 33.24657], [127.07417, 33.247448], [127.077242, 33.249862], [127.080424, 33.252276], [127.083387, 33.254909], [127.086459, 33.257542], [127.089421, 33.260066], [127.092274, 33.262699], [127.095127, 33.265442], [127.09787, 33.268185], [127.100503, 33.271038], [127.103246, 33.27389], [127.105769, 33.276743], [127.108403, 33.279705], [127.110816, 33.282558], [127.113121, 33.285521], [127.114327, 33.287166], [127.115534, 33.288593], [127.116632, 33.290238], [127.117838, 33.291665], [127.118936, 33.293311], [127.120033, 33.294847], [127.12113, 33.296383], [127.122118, 33.297919], [127.123105, 33.299565], [127.124202, 33.301101], [127.12508, 33.302746], [127.126177, 33.304283], [127.127165, 33.305928], [127.128152, 33.307464], [127.12903, 33.30911], [127.131334, 33.311524], [127.133967, 33.314486], [127.136381, 33.317449], [127.138685, 33.320411], [127.139892, 33.322057], [127.141099, 33.323483], [127.142196, 33.325129], [127.143293, 33.326556], [127.1445, 33.328092], [127.145597, 33.329737], [127.146585, 33.331164], [127.147572, 33.33281], [127.14867, 33.334346], [127.149767, 33.335991], [127.150645, 33.337527], [127.151632, 33.339173], [127.152729, 33.340709], [127.153717, 33.342355], [127.154594, 33.344001], [127.155582, 33.345647], [127.15635, 33.347183], [127.157337, 33.348938], [127.158105, 33.350474], [127.159093, 33.35223], [127.159861, 33.353876], [127.160739, 33.355521], [127.161507, 33.357167], [127.162275, 33.358923], [127.163043, 33.360568], [127.163811, 33.362214], [127.164469, 33.36386], [127.165127, 33.365616], [127.165786, 33.367261], [127.166554, 33.369017], [127.167212, 33.370772], [127.16787, 33.372528], [127.168529, 33.374174], [127.169077, 33.375929], [127.169626, 33.377685], [127.170284, 33.37944], [127.170723, 33.381086], [127.171272, 33.382951], [127.17182, 33.384707], [127.172369, 33.386462], [127.173027, 33.389315], [127.173795, 33.390193], [127.174892, 33.391729], [127.17599, 33.393265], [127.177197, 33.394801], [127.178294, 33.396337], [127.179391, 33.397873], [127.180488, 33.399519], [127.181366, 33.401055], [127.182463, 33.402701], [127.183451, 33.404237], [127.184548, 33.405773], [127.185426, 33.407419], [127.186413, 33.409064], [127.187291, 33.4106], [127.188278, 33.412356], [127.189266, 33.413892], [127.190034, 33.415538], [127.190802, 33.417184], [127.191789, 33.418829], [127.192557, 33.420475], [127.193435, 33.422231], [127.194203, 33.423876], [127.195081, 33.425632], [127.195739, 33.427168], [127.196507, 33.428924], [127.197165, 33.430569], [127.198043, 33.432325], [127.198702, 33.433971], [127.19936, 33.435726], [127.200018, 33.437372], [127.200676, 33.439237], [127.201225, 33.440883], [127.201774, 33.442638], [127.202322, 33.444284], [127.202981, 33.446149], [127.203529, 33.447795], [127.204078, 33.449551], [127.204517, 33.451306], [127.205065, 33.453171], [127.205504, 33.454817], [127.205943, 33.456573], [127.206382, 33.458328], [127.206821, 33.460194], [127.20704, 33.461291], [127.207163, 33.461721], [127.207699, 33.463595], [127.208247, 33.465241], [127.208796, 33.467106], [127.209235, 33.468861], [127.209673, 33.470617], [127.210003, 33.472372], [127.210441, 33.474128], [127.21088, 33.475883], [127.211319, 33.477749], [127.211648, 33.479394], [127.211978, 33.48126], [127.212307, 33.483015], [127.212636, 33.484771], [127.212855, 33.486526], [127.213184, 33.488391], [127.213294, 33.490147], [127.213514, 33.491902], [127.213843, 33.493658], [127.213953, 33.495523], [127.214062, 33.497388], [127.214282, 33.499144], [127.214391, 33.501009], [127.214501, 33.502655], [127.214501, 33.50452], [127.214611, 33.506276], [127.214611, 33.508141], [127.21483, 33.509896], [127.21483, 33.511762], [127.21483, 33.513517], [127.21483, 33.515382], [127.214611, 33.517138], [127.214501, 33.518893], [127.214501, 33.520759], [127.214391, 33.522514], [127.214282, 33.52427], [127.214282, 33.526135], [127.213953, 33.52789], [127.213843, 33.529756], [127.213514, 33.531511], [127.213514, 33.532169], [127.213404, 33.533376], [127.210332, 33.551041], [127.204736, 33.568706], [127.197933, 33.585273], [127.189924, 33.601951], [127.178733, 33.617531], [127.156899, 33.641998], [127.132541, 33.662845], [127.129469, 33.664381], [127.1016, 33.682046], [127.082948, 33.690823], [127.063637, 33.698065], [127.043778, 33.70388], [127.021944, 33.708049], [127.000878, 33.711121], [126.9921, 33.71167], [126.977727, 33.721435], [126.948542, 33.739648], [126.918808, 33.752705], [126.904983, 33.757313], [126.922429, 33.759837], [126.939874, 33.763019], [126.957319, 33.766091], [126.964122, 33.767627], [126.978385, 33.77026], [126.995831, 33.771906], [127.012618, 33.773442], [127.029405, 33.775417], [127.04685, 33.777063], [127.064295, 33.779038], [127.081083, 33.780135], [127.098528, 33.782219], [127.115315, 33.784304], [127.132541, 33.78584], [127.149986, 33.787486], [127.156899, 33.787925], [127.174344, 33.79001], [127.191679, 33.792094], [127.208467, 33.79363], [127.22657, 33.795276], [127.242589, 33.797251], [127.260035, 33.798787], [127.265411, 33.799665], [127.273201, 33.800433], [127.321477, 33.80537], [127.369972, 33.809199], [127.413203, 33.812612], [127.622766, 33.829508], [127.653927, 33.833787], [127.709445, 33.849587], [127.749273, 33.867032], [127.785699, 33.891719], [127.835731, 33.948554], [127.897942, 34.013069], [127.996251, 34.117631], [127.999323, 34.118509], [128.0, 34.118697], [128.75, 34.326406], [128.813549, 34.343982], [128.813313, 34.343917], [128.887965, 34.307977], [128.8883, 34.3083], [128.7933, 34.2166], [128.75, 34.183619], [128.6883, 34.1366], [128.435, 33.84], [128.425, 33.79], [128.3616, 33.7516], [128.0, 33.396458], [127.922434, 33.320087], [127.8716, 33.27], [127.86, 33.2283], [127.805, 33.145], [127.6983, 32.9583], [127.685, 32.95], [127.15, 32.5666], [126.535888, 32.1833], [126.000509, 32.1833]]]]}}, {"type": "Feature", "properties": {"id": "ZONE_III", "name_ko": "특정해역 III (서남해)", "name_en": "Zone III (SW Sea)", "color": "#06b6d4", "name": "특정어업수역Ⅲ"}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[124.125526, 35.000703], [125.18669, 35.000703], [125.185796, 35.0], [125.080023, 34.916826], [125.03065, 34.87612], [125.006621, 34.855273], [124.994223, 34.84112], [124.977326, 34.819066], [124.963172, 34.793502], [124.95615, 34.772435], [124.94079, 34.734253], [124.935194, 34.714284], [124.932232, 34.691024], [124.915664, 34.596775], [124.90173, 34.515144], [124.887686, 34.427588], [124.859268, 34.25862], [124.834143, 34.112474], [124.846651, 34.040389], [124.858171, 34.003962], [124.878689, 33.970498], [124.904582, 33.937801], [124.944959, 33.90719], [124.983361, 33.881954], [125.012985, 33.865606], [125.061152, 33.852111], [125.395356, 33.801969], [125.578823, 33.774791], [125.867039, 33.732078], [125.875597, 33.730761], [125.884265, 33.729444], [125.892823, 33.728238], [125.901381, 33.726921], [125.90994, 33.725714], [125.918607, 33.724397], [125.927165, 33.723081], [125.935724, 33.721764], [125.944282, 33.720557], [125.952949, 33.71935], [125.960959, 33.718143], [125.969078, 33.716937], [125.977636, 33.7154], [125.986304, 33.713864], [125.993436, 33.712767], [126.001994, 33.711012], [126.010662, 33.709256], [126.014283, 33.708598], [126.022841, 33.706842], [126.031508, 33.705087], [126.040067, 33.703441], [126.048625, 33.701686], [126.061242, 33.699162], [126.060803, 33.697187], [126.060584, 33.695212], [126.060255, 33.692469], [126.060035, 33.690604], [126.059816, 33.688848], [126.059706, 33.687093], [126.059487, 33.685228], [126.059487, 33.683472], [126.059158, 33.681717], [126.059158, 33.679851], [126.059048, 33.678096], [126.059048, 33.676231], [126.059048, 33.674475], [126.059048, 33.67272], [126.058938, 33.670854], [126.059048, 33.669099], [126.059048, 33.667343], [126.059048, 33.665478], [126.059158, 33.663723], [126.059158, 33.661857], [126.059377, 33.660102], [126.059487, 33.658237], [126.059706, 33.656591], [126.059816, 33.654726], [126.060035, 33.65297], [126.060255, 33.651215], [126.060365, 33.649349], [126.060694, 33.647704], [126.060913, 33.645838], [126.061242, 33.644083], [126.061462, 33.642218], [126.061901, 33.640572], [126.06212, 33.638707], [126.062559, 33.637061], [126.062998, 33.635196], [126.063327, 33.63344], [126.063766, 33.631685], [126.064205, 33.629929], [126.064753, 33.628064], [126.065083, 33.626418], [126.065631, 33.624663], [126.06618, 33.622907], [126.066728, 33.621152], [126.067277, 33.619506], [126.067826, 33.617641], [126.068374, 33.615995], [126.069032, 33.614239], [126.069691, 33.612484], [126.070239, 33.610728], [126.070898, 33.609083], [126.071666, 33.607437], [126.072324, 33.605681], [126.072982, 33.604035], [126.07375, 33.60228], [126.074628, 33.600634], [126.075286, 33.598879], [126.076054, 33.597343], [126.076932, 33.595587], [126.0777, 33.593941], [126.078688, 33.592295], [126.079456, 33.59065], [126.080333, 33.589004], [126.081211, 33.587358], [126.082199, 33.585822], [126.083076, 33.584176], [126.083625, 33.583189], [126.082418, 33.58253], [126.079017, 33.580336], [126.075725, 33.578032], [126.072324, 33.575838], [126.068923, 33.573533], [126.065631, 33.57112], [126.062449, 33.568816], [126.059377, 33.566402], [126.056195, 33.563768], [126.053123, 33.561355], [126.05027, 33.558721], [126.047308, 33.556088], [126.044346, 33.553565], [126.041493, 33.550822], [126.03875, 33.547969], [126.036117, 33.545226], [126.033374, 33.542373], [126.03074, 33.539521], [126.028327, 33.536668], [126.025913, 33.533705], [126.023499, 33.530743], [126.022292, 33.529207], [126.021195, 33.527671], [126.019988, 33.526135], [126.018891, 33.524599], [126.017684, 33.523063], [126.016587, 33.521527], [126.015489, 33.519991], [126.014392, 33.518454], [126.013514, 33.516809], [126.012417, 33.515273], [126.01143, 33.513627], [126.010333, 33.512091], [126.009455, 33.510445], [126.008467, 33.508909], [126.00748, 33.507153], [126.006712, 33.505617], [126.005724, 33.503971], [126.004847, 33.502326], [126.003969, 33.50079], [126.003091, 33.499034], [126.002213, 33.497388], [126.001445, 33.495743], [126.000677, 33.494097], [125.999909, 33.492341], [125.999032, 33.490586], [125.998154, 33.489927], [125.994972, 33.487623], [125.9919, 33.48521], [125.988608, 33.482686], [125.985536, 33.480272], [125.982354, 33.477749], [125.979392, 33.475115], [125.976539, 33.472372], [125.973577, 33.469849], [125.970834, 33.467106], [125.968091, 33.464363], [125.965348, 33.46151], [125.962824, 33.458657], [125.960081, 33.455695], [125.957667, 33.452842], [125.955034, 33.44988], [125.95273, 33.446917], [125.951523, 33.445272], [125.950426, 33.443845], [125.949219, 33.442419], [125.948122, 33.440773], [125.947134, 33.439237], [125.946037, 33.437701], [125.94494, 33.436165], [125.943843, 33.434519], [125.942746, 33.432983], [125.941868, 33.431337], [125.940771, 33.429801], [125.939783, 33.428156], [125.938796, 33.426619], [125.937918, 33.424864], [125.93693, 33.423328], [125.936053, 33.421792], [125.935065, 33.420036], [125.934297, 33.4185], [125.93331, 33.416745], [125.932432, 33.415209], [125.931664, 33.413453], [125.930896, 33.411807], [125.930018, 33.410052], [125.92925, 33.408406], [125.928592, 33.406651], [125.927714, 33.405005], [125.927056, 33.403249], [125.926397, 33.401603], [125.925739, 33.399848], [125.925081, 33.398202], [125.924422, 33.396447], [125.923654, 33.394801], [125.923106, 33.393045], [125.922448, 33.3914], [125.921899, 33.389534], [125.92135, 33.387779], [125.920911, 33.386023], [125.920363, 33.384268], [125.919814, 33.382293], [125.918607, 33.37955], [125.917839, 33.377794], [125.917181, 33.376149], [125.916523, 33.374393], [125.915864, 33.372747], [125.915206, 33.370992], [125.914548, 33.369346], [125.913999, 33.367591], [125.913451, 33.365835], [125.912792, 33.364079], [125.912244, 33.362434], [125.911695, 33.360568], [125.911146, 33.358923], [125.910708, 33.357057], [125.910159, 33.355412], [125.90961, 33.353546], [125.909281, 33.351901], [125.908842, 33.350035], [125.908403, 33.34828], [125.908184, 33.346524], [125.907745, 33.344659], [125.907306, 33.343013], [125.907087, 33.341148], [125.906758, 33.339393], [125.906429, 33.337527], [125.906099, 33.335882], [125.90599, 33.334016], [125.90566, 33.332261], [125.905441, 33.330396], [125.905331, 33.32864], [125.905002, 33.326775], [125.904892, 33.32491], [125.904783, 33.323264], [125.904673, 33.321399], [125.904673, 33.319643], [125.904454, 33.317778], [125.904454, 33.316022], [125.904344, 33.314157], [125.904344, 33.312402], [125.904344, 33.310646], [125.904344, 33.308781], [125.904454, 33.307025], [125.904454, 33.30516], [125.904454, 33.305051], [125.904673, 33.303405], [125.904673, 33.30154], [125.904783, 33.299784], [125.904892, 33.297919], [125.905002, 33.296163], [125.905331, 33.294298], [125.905441, 33.292652], [125.90566, 33.290787], [125.90599, 33.288922], [125.906099, 33.287166], [125.906429, 33.285301], [125.906648, 33.283546], [125.907087, 33.28179], [125.907306, 33.280035], [125.907745, 33.278279], [125.907965, 33.276524], [125.908403, 33.274658], [125.908842, 33.272903], [125.909391, 33.271147], [125.90972, 33.269282], [125.910159, 33.267636], [125.910708, 33.265771], [125.911146, 33.264125], [125.911695, 33.26226], [125.912244, 33.260614], [125.912792, 33.258749], [125.913451, 33.257103], [125.913999, 33.255348], [125.914548, 33.253592], [125.915206, 33.251946], [125.915864, 33.250081], [125.916523, 33.248435], [125.917181, 33.24668], [125.917839, 33.245034], [125.918607, 33.243279], [125.919375, 33.241633], [125.920034, 33.239877], [125.920911, 33.238341], [125.921679, 33.236586], [125.922448, 33.23494], [125.923325, 33.233184], [125.924203, 33.231648], [125.925081, 33.229893], [125.925849, 33.228357], [125.926836, 33.226601], [125.927714, 33.224956], [125.928702, 33.223419], [125.929689, 33.221664], [125.930567, 33.220128], [125.931554, 33.218482], [125.932651, 33.216946], [125.933529, 33.2153], [125.934626, 33.213764], [125.935724, 33.212118], [125.936821, 33.210582], [125.937918, 33.209046], [125.939015, 33.20751], [125.940112, 33.205864], [125.941319, 33.204438], [125.942307, 33.202792], [125.943623, 33.201366], [125.94483, 33.19994], [125.947244, 33.196867], [125.949658, 33.194015], [125.952181, 33.191052], [125.954815, 33.1882], [125.957448, 33.185347], [125.960191, 33.182494], [125.96063, 33.182165], [125.961837, 33.179532], [125.962824, 33.177886], [125.963702, 33.17624], [125.96458, 33.174485], [125.965457, 33.172949], [125.966445, 33.171303], [125.967323, 33.169657], [125.96831, 33.168011], [125.969298, 33.166475], [125.970395, 33.164829], [125.971273, 33.163293], [125.97237, 33.161648], [125.973467, 33.160111], [125.974564, 33.158466], [125.975661, 33.157039], [125.976649, 33.155394], [125.977746, 33.153967], [125.978953, 33.152321], [125.98005, 33.150785], [125.982464, 33.147823], [125.984768, 33.14486], [125.987401, 33.141898], [125.989925, 33.138936], [125.992558, 33.136083], [125.995191, 33.13323], [125.997934, 33.130487], [126.000154, 33.128268], [126.000509, 32.1833], [125.4166, 32.1833], [125.291527, 32.296033], [125.291527, 32.296033], [124.170389, 33.300272], [124.1333, 33.3333], [124.0083, 34.0], [124.125, 35.0], [124.125, 35.0], [124.125526, 35.000703]]]]}}, {"type": "Feature", "properties": {"id": "ZONE_IV", "name_ko": "특정해역 IV (서해)", "name_en": "Zone IV (West Sea)", "color": "#f59e0b", "name": "특정어업수역Ⅳ"}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[124.5, 35.5], [124.5, 36.149546], [124.5, 36.75], [124.3333, 37.0], [124.727778, 37.0], [124.727778, 37.00285], [125.228908, 37.002852], [125.486775, 37.002853], [125.483132, 36.996554], [125.428382, 36.902634], [125.408742, 36.867524], [125.388993, 36.834718], [125.388133, 36.833333], [125.388133, 36.833333], [125.380544, 36.821113], [125.349823, 36.764388], [125.31186, 36.697459], [125.301985, 36.671346], [125.293537, 36.640625], [125.293537, 36.629214], [125.29222, 36.616816], [125.295073, 36.587191], [125.299352, 36.573586], [125.309227, 36.545169], [125.324807, 36.515654], [125.35871, 36.471328], [125.448022, 36.366656], [125.569591, 36.226544], [125.587914, 36.206027], [125.6, 36.192052], [125.636358, 36.15], [125.73779, 36.032561], [125.758966, 35.997121], [125.791443, 35.926023], [125.809876, 35.883672], [125.822713, 35.845819], [125.832697, 35.814878], [125.838183, 35.788435], [125.848387, 35.720629], [125.848833, 35.716667], [125.851349, 35.694296], [125.852666, 35.683983], [125.848387, 35.66555], [125.81218, 35.565486], [125.785408, 35.496143], [125.775643, 35.465093], [125.768511, 35.450061], [125.746603, 35.433333], [125.686771, 35.387631], [125.501126, 35.244228], [125.392723, 35.160732], [125.37429, 35.148004], [125.261667, 35.059608], [125.229351, 35.034225], [125.185796, 35.0], [125.18669, 35.000703], [124.125526, 35.000703], [124.5, 35.5]]]]}}]} \ No newline at end of file diff --git a/frontend/src/lib/map/hooks/useMapLayers.ts b/frontend/src/lib/map/hooks/useMapLayers.ts index d92d39a..cfd72bd 100644 --- a/frontend/src/lib/map/hooks/useMapLayers.ts +++ b/frontend/src/lib/map/hooks/useMapLayers.ts @@ -7,9 +7,11 @@ import { useRef, useEffect } from 'react'; import type { MapboxOverlay } from '@deck.gl/mapbox'; import type { Layer } from 'deck.gl'; +import type maplibregl from 'maplibre-gl'; export interface MapHandle { overlay: MapboxOverlay | null; + map: maplibregl.Map | null; } /** @@ -26,6 +28,7 @@ export function useMapLayers( const prevRef = useRef([]); const rafRef = useRef(0); + // deps 변경 시에만 레이어 갱신 (매 렌더 아닌 deps diff 기반) useEffect(() => { if (shallowEqual(prevRef.current, deps)) return; prevRef.current = deps; @@ -34,13 +37,16 @@ export function useMapLayers( rafRef.current = requestAnimationFrame(() => { handleRef.current?.overlay?.setProps({ layers: buildLayers() }); }); + }); + // 언마운트 시에만 레이어 초기화 — stale WebGL 참조 방지 + useEffect(() => { return () => { cancelAnimationFrame(rafRef.current); - // 언마운트 시 레이어 초기화 — stale WebGL 참조 방지 try { handleRef.current?.overlay?.setProps({ layers: [] }); } catch { /* finalized */ } }; - }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); } /** diff --git a/frontend/src/lib/map/index.ts b/frontend/src/lib/map/index.ts index 77801ac..2c855a6 100644 --- a/frontend/src/lib/map/index.ts +++ b/frontend/src/lib/map/index.ts @@ -15,5 +15,7 @@ export { createHeatmapLayer, createZoneLayer, createStaticLayers, + createGeoJsonLayer, createGearPolygonLayer, + createShipIconLayer, createGearIconLayer, type ShipIconData, type GearIconData, } from './layers'; export { useMapLayers, useStoreLayerSync } from './hooks/useMapLayers'; diff --git a/frontend/src/lib/map/layers/geojson.ts b/frontend/src/lib/map/layers/geojson.ts new file mode 100644 index 0000000..1326198 --- /dev/null +++ b/frontend/src/lib/map/layers/geojson.ts @@ -0,0 +1,82 @@ +/** + * GeoJSON 폴리곤 레이어 팩토리 (deck.gl GeoJsonLayer) + * + * 용도: 어구 그룹 폴리곤, 특정해역 I~IV 경계 등 + * deck.gl GeoJsonLayer로 GeoJSON Feature/FeatureCollection을 직접 렌더링. + */ +import { GeoJsonLayer } from 'deck.gl'; + +function hexToRgba(hex: string, alpha = 200): [number, number, number, number] { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return [r, g, b, alpha]; +} + +interface GeoJsonPolygonOptions { + fillOpacity?: number; + lineColor?: string; + lineWidth?: number; + pickable?: boolean; +} + +/** + * GeoJSON FeatureCollection/Feature 배열을 폴리곤 레이어로 렌더링. + * feature.properties.color 가 있으면 해당 색상 사용, 없으면 defaultColor. + */ +export function createGeoJsonLayer( + id: string, + data: unknown, + defaultColor = '#3b82f6', + options: GeoJsonPolygonOptions = {}, +) { + const { fillOpacity = 30, lineColor, lineWidth = 1.5, pickable = true } = options; + + return new GeoJsonLayer({ + id, + data: data as GeoJSON.FeatureCollection, + pickable, + stroked: true, + filled: true, + extruded: false, + getFillColor: (f: GeoJSON.Feature) => { + const color = (f.properties as Record)?.color as string | undefined; + return hexToRgba(color ?? defaultColor, fillOpacity); + }, + getLineColor: (f: GeoJSON.Feature) => { + const color = (f.properties as Record)?.color as string | undefined; + return hexToRgba(lineColor ?? color ?? defaultColor, 180); + }, + getLineWidth: lineWidth, + lineWidthUnits: 'pixels' as const, + // tooltip용 데이터 + autoHighlight: true, + highlightColor: [255, 255, 255, 40], + }); +} + +/** + * 어구 그룹 폴리곤 전용 레이어. + * data: {polygon: GeoJSON.Geometry, color: string, label: string, risk: string}[] + */ +export function createGearPolygonLayer( + id: string, + data: { polygon: unknown; color: string; label: string; risk: string }[], +) { + // Convert each item to a GeoJSON Feature + const fc: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: data + .filter(d => d.polygon != null) + .map((d, i) => ({ + type: 'Feature' as const, + properties: { color: d.color, label: d.label, risk: d.risk, idx: i }, + geometry: d.polygon as GeoJSON.Geometry, + })), + }; + + return createGeoJsonLayer(id, fc, '#64748b', { + fillOpacity: 25, + lineWidth: 2, + }); +} diff --git a/frontend/src/lib/map/layers/icons.ts b/frontend/src/lib/map/layers/icons.ts new file mode 100644 index 0000000..e0b3bf9 --- /dev/null +++ b/frontend/src/lib/map/layers/icons.ts @@ -0,0 +1,121 @@ +/** + * 선박/어구 아이콘 레이어 (deck.gl IconLayer) + * + * 선박: 삼각형, COG 방향 반영 (getAngle) + * 어구: 마름모, 고정 방향 + * SVG Data URI + mask 모드로 색상 동적 적용 + */ +import { IconLayer } from 'deck.gl'; + +// ── SVG 아이콘 생성 (모듈 로드 시 1회) ── + +const ICON_SIZE = 64; + +function createShipTriangleSvg(): string { + const s = ICON_SIZE; + return ` + + `; +} + +function createGearDiamondSvg(): string { + const s = ICON_SIZE; + return ` + + `; +} + +function svgToDataUri(svg: string): string { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +const SHIP_TRIANGLE_URI = svgToDataUri(createShipTriangleSvg()); +const GEAR_DIAMOND_URI = svgToDataUri(createGearDiamondSvg()); + +export const SHIP_ICON_MAPPING = { + 'ship-triangle': { url: SHIP_TRIANGLE_URI, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true }, + 'gear-diamond': { url: GEAR_DIAMOND_URI, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE / 2, mask: true }, +} as const; + +function hexToRgba(hex: string, alpha = 220): [number, number, number, number] { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return [r, g, b, alpha]; +} + +// ── 데이터 타입 ── + +export interface ShipIconData { + lat: number; + lon: number; + cog?: number; // 침로 (0=North, 시계 방향) + color?: string; + size?: number; + label?: string; + isParent?: boolean; +} + +export interface GearIconData { + lat: number; + lon: number; + color?: string; + size?: number; + label?: string; +} + +// ── 레이어 팩토리 ── + +/** 선박 아이콘 레이어 — COG 방향 반영 삼각형 */ +export function createShipIconLayer( + id: string, + data: ShipIconData[], + defaultColor = '#06b6d4', + defaultSize = 20, +) { + return new IconLayer({ + id, + data, + pickable: true, + iconAtlas: SHIP_TRIANGLE_URI, + iconMapping: { + ship: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true }, + }, + getIcon: () => 'ship', + getPosition: (d) => [d.lon, d.lat], + getSize: (d) => d.size ?? defaultSize, + getAngle: (d) => -(d.cog ?? 0), // deck.gl: 반시계 방향이므로 음수 + getColor: (d) => hexToRgba(d.color ?? defaultColor), + sizeUnits: 'pixels', + sizeMinPixels: 8, + sizeMaxPixels: 40, + billboard: false, + }); +} + +/** 어구 아이콘 레이어 — 마름모 (방향 고정) */ +export function createGearIconLayer( + id: string, + data: GearIconData[], + defaultColor = '#f59e0b', + defaultSize = 16, +) { + return new IconLayer({ + id, + data, + pickable: true, + iconAtlas: GEAR_DIAMOND_URI, + iconMapping: { + gear: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE / 2, mask: true }, + }, + getIcon: () => 'gear', + getPosition: (d) => [d.lon, d.lat], + getSize: (d) => d.size ?? defaultSize, + getAngle: 0, + getColor: (d) => hexToRgba(d.color ?? defaultColor), + sizeUnits: 'pixels', + sizeMinPixels: 6, + sizeMaxPixels: 30, + billboard: false, + }); +} diff --git a/frontend/src/lib/map/layers/index.ts b/frontend/src/lib/map/layers/index.ts index ba2f471..44cdf75 100644 --- a/frontend/src/lib/map/layers/index.ts +++ b/frontend/src/lib/map/layers/index.ts @@ -4,3 +4,6 @@ export { createPolylineLayer } from './polyline'; export { createHeatmapLayer } from './heatmap'; export { createZoneLayer } from './zones'; export { createEEZStaticLayer, createNLLStaticLayer, createStaticLayers } from './static'; +export { createGeoJsonLayer, createGearPolygonLayer } from './geojson'; +export { createShipIconLayer, createGearIconLayer, type ShipIconData, type GearIconData } from './icons'; +export { createTripsLayer, type TripsData } from './trips'; diff --git a/frontend/src/lib/map/layers/trips.ts b/frontend/src/lib/map/layers/trips.ts new file mode 100644 index 0000000..33aec2d --- /dev/null +++ b/frontend/src/lib/map/layers/trips.ts @@ -0,0 +1,42 @@ +/** + * TripsLayer 팩토리 — deck.gl/geo-layers의 TripsLayer 래퍼 + * + * 어구 멤버 궤적 애니메이션 (fade trail) 전용. + */ +import { TripsLayer } from '@deck.gl/geo-layers'; + +export interface TripsData { + id: string; + path: [number, number][]; + timestamps: number[]; + color: [number, number, number, number]; +} + +/** + * 궤적 애니메이션 레이어 생성. + * @param id — 레이어 ID + * @param data — MMSI별 궤적 데이터 + * @param currentTime — startTime 기준 상대 시간 (ms) + * @param trailLength — 페이드 길이 (ms, 기본 1시간) + */ +export function createTripsLayer( + id: string, + data: TripsData[], + currentTime: number, + trailLength = 3_600_000, +) { + return new TripsLayer({ + id, + data, + getPath: (d) => d.path, + getTimestamps: (d) => d.timestamps, + getColor: (d) => d.color, + currentTime, + fadeTrail: true, + trailLength, + widthMinPixels: 2, + widthMaxPixels: 4, + jointRounded: true, + capRounded: true, + }); +} diff --git a/frontend/src/services/vesselAnalysisApi.ts b/frontend/src/services/vesselAnalysisApi.ts index 9fbe645..e91e7da 100644 --- a/frontend/src/services/vesselAnalysisApi.ts +++ b/frontend/src/services/vesselAnalysisApi.ts @@ -64,10 +64,18 @@ export interface GearGroupItem { resolution: { status: string; selectedParentMmsi: string | null; + selectedParentName: string | null; + topScore: number | null; + confidence: number | null; + secondScore: number | null; + scoreMargin: number | null; + decisionSource: string | null; + stableCycles: number | null; approvedAt: string | null; manualComment: string | null; } | null; candidateCount?: number; + liveTopScore?: number; // correlation_scores 실시간 최고 점수 } export interface GroupsResponse { @@ -86,8 +94,9 @@ export function fetchVesselAnalysis() { return apiGet('/vessel-analysis'); } -export function fetchGroups() { - return apiGet('/vessel-analysis/groups'); +export function fetchGroups(groupType?: string) { + const qs = groupType ? `?groupType=${groupType}` : ''; + return apiGet(`/vessel-analysis/groups${qs}`); } export function fetchGroupDetail(groupKey: string) { @@ -99,6 +108,74 @@ export function fetchGroupCorrelations(groupKey: string, minScore?: number) { return apiGet(`/vessel-analysis/groups/${encodeURIComponent(groupKey)}/correlations${qs}`); } +// ─── 후보 상세 메트릭 + 모선 확정/제외 ───────────── + +export interface CandidateMetricItem { + observedAt: string; + proximityRatio: number | null; + visitScore: number | null; + activitySync: number | null; + dtwSimilarity: number | null; + speedCorrelation: number | null; + headingCoherence: number | null; + driftSimilarity: number | null; + shadowStay: boolean; + shadowReturn: boolean; + gearGroupActiveRatio: number | null; +} + +export function fetchCandidateMetrics(groupKey: string, targetMmsi: string) { + return apiGet<{ items: CandidateMetricItem[]; count: number }>( + `/vessel-analysis/groups/${encodeURIComponent(groupKey)}/candidates/${encodeURIComponent(targetMmsi)}/metrics`, + ); +} + +export async function resolveParent( + groupKey: string, + action: 'confirm' | 'reject', + targetMmsi: string, + comment = '', +): Promise<{ ok: boolean; message?: string }> { + const res = await fetch(`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/resolve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ action, targetMmsi, comment }), + }); + return res.json(); +} + +// ─── 선박 항적 조회 (signal-batch) ───────────── + +/** CompactVesselTrack 응답 */ +export interface VesselTrack { + vesselId: string; + shipName: string; + geometry: [number, number][]; // [lon, lat][] + timestamps: string[]; // Unix timestamp (초) 문자열 배열 + speeds: number[]; + pointCount: number; +} + +/** + * 선박 항적 일괄 조회. + * POST /api/prediction/v2/tracks/vessels (백엔드 프록시 → signal-batch) + */ +export async function fetchVesselTracks( + vessels: string[], + startTime: string, + endTime: string, +): Promise { + const res = await fetch(`${API_BASE}/vessel-analysis/tracks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ startTime, endTime, vessels }), + }); + if (!res.ok) throw new Error(`tracks API ${res.status}`); + return res.json(); +} + // ─── 필터/유틸 ───────────────────────────────── /** diff --git a/frontend/src/shared/constants/catalogRegistry.ts b/frontend/src/shared/constants/catalogRegistry.ts index f463ad8..658318d 100644 --- a/frontend/src/shared/constants/catalogRegistry.ts +++ b/frontend/src/shared/constants/catalogRegistry.ts @@ -41,6 +41,8 @@ import { CONNECTION_STATUSES } from './connectionStatuses'; import { TRAINING_ZONE_TYPES } from './trainingZoneTypes'; import { MLOPS_JOB_STATUSES } from './mlopsJobStatuses'; import { THREAT_LEVELS, AGENT_PERM_TYPES, AGENT_EXEC_RESULTS } from './aiSecurityStatuses'; +import { ZONE_CODES } from './zoneCodes'; +import { GEAR_VIOLATION_CODES } from './gearViolationCodes'; /** * 카탈로그 공통 메타 — 쇼케이스 렌더와 UI 일관성을 위한 최소 스키마 @@ -297,6 +299,24 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [ source: 'AI Agent 보안 (SER-11)', items: AGENT_EXEC_RESULTS, }, + { + id: 'zone-code', + showcaseId: 'TRK-CAT-zone-code', + titleKo: '수역 코드', + titleEn: 'Zone Codes', + description: '특정해역 I~IV / 영해 / 접속수역 / EEZ 외', + source: 'prediction/algorithms/location.py classify_zone()', + items: ZONE_CODES, + }, + { + id: 'gear-violation', + showcaseId: 'TRK-CAT-gear-violation', + titleKo: '어구 위반 G코드', + titleEn: 'Gear Violation G-Codes', + description: 'G-01~G-06 어구 위반 유형 (DAR-03)', + source: 'prediction/algorithms/gear_violation.py', + items: GEAR_VIOLATION_CODES, + }, ]; /** ID로 특정 카탈로그 조회 */ diff --git a/frontend/src/shared/constants/gearViolationCodes.ts b/frontend/src/shared/constants/gearViolationCodes.ts new file mode 100644 index 0000000..9d7f9da --- /dev/null +++ b/frontend/src/shared/constants/gearViolationCodes.ts @@ -0,0 +1,93 @@ +/** + * 어구 위반 G코드 카탈로그 (DAR-03) + * + * SSOT: prediction/algorithms/gear_violation.py + * 사용처: GearDetection, GearDetailPanel + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type GearViolationCode = 'G-01' | 'G-02' | 'G-03' | 'G-04' | 'G-05' | 'G-06'; + +interface GearViolationMeta { + code: GearViolationCode; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; + desc: { ko: string; en: string }; + score: number; +} + +export const GEAR_VIOLATION_CODES: Record = { + 'G-01': { + code: 'G-01', + i18nKey: 'gearViolation.G01', + fallback: { ko: '수역 위반', en: 'Zone Violation' }, + intent: 'high', + desc: { ko: '비허가 수역에서 조업', en: 'Fishing in unauthorized zone' }, + score: 15, + }, + 'G-02': { + code: 'G-02', + i18nKey: 'gearViolation.G02', + fallback: { ko: '금어기 위반', en: 'Closed Season' }, + intent: 'high', + desc: { ko: '금어기 내 조업', en: 'Fishing during closed season' }, + score: 15, + }, + 'G-03': { + code: 'G-03', + i18nKey: 'gearViolation.G03', + fallback: { ko: '미등록 어구', en: 'Unregistered Gear' }, + intent: 'warning', + desc: { ko: '등록되지 않은 어구 사용', en: 'Unregistered gear in use' }, + score: 10, + }, + 'G-04': { + code: 'G-04', + i18nKey: 'gearViolation.G04', + fallback: { ko: 'MMSI 조작', en: 'MMSI Tampering' }, + intent: 'critical', + desc: { ko: '어구 신호 주기적 단속 (조작 의심)', en: 'Gear signal on/off cycling' }, + score: 10, + }, + 'G-05': { + code: 'G-05', + i18nKey: 'gearViolation.G05', + fallback: { ko: '어구 이동', en: 'Gear Drift' }, + intent: 'warning', + desc: { ko: '고정 어구 인위적 이동 (조류보정 초과)', en: 'Fixed gear artificial repositioning' }, + score: 5, + }, + 'G-06': { + code: 'G-06', + i18nKey: 'gearViolation.G06', + fallback: { ko: '쌍끌이 공조', en: 'Pair Trawl' }, + intent: 'critical', + desc: { ko: '쌍끌이 트롤 공조 불법조업', en: 'Cooperative pair trawl fishing' }, + score: 20, + }, +}; + +export function getGearViolationIntent(code: string): BadgeIntent { + return GEAR_VIOLATION_CODES[code as GearViolationCode]?.intent ?? 'muted'; +} + +export function getGearViolationLabel( + code: string, + t: (k: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = GEAR_VIOLATION_CODES[code as GearViolationCode]; + if (!meta) return code; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} + +export function getGearViolationDesc( + code: string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = GEAR_VIOLATION_CODES[code as GearViolationCode]; + if (!meta) return ''; + return meta.desc[lang]; +} diff --git a/frontend/src/shared/constants/zoneCodes.ts b/frontend/src/shared/constants/zoneCodes.ts new file mode 100644 index 0000000..5bcf582 --- /dev/null +++ b/frontend/src/shared/constants/zoneCodes.ts @@ -0,0 +1,95 @@ +/** + * 수역 코드 카탈로그 + * + * SSOT: prediction/algorithms/location.py classify_zone() + * 사용처: GearDetection, RealVesselAnalysis, VesselDetail + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type ZoneCode = + | 'ZONE_I' + | 'ZONE_II' + | 'ZONE_III' + | 'ZONE_IV' + | 'TERRITORIAL_SEA' + | 'CONTIGUOUS_ZONE' + | 'EEZ_OR_BEYOND'; + +interface ZoneCodeMeta { + code: ZoneCode; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; + allowedGears: string[]; +} + +export const ZONE_CODES: Record = { + ZONE_I: { + code: 'ZONE_I', + i18nKey: 'zone.ZONE_I', + fallback: { ko: '특정해역 I (동해)', en: 'Zone I (East Sea)' }, + intent: 'purple', + allowedGears: ['PS', 'FC'], + }, + ZONE_II: { + code: 'ZONE_II', + i18nKey: 'zone.ZONE_II', + fallback: { ko: '특정해역 II (남해)', en: 'Zone II (South Sea)' }, + intent: 'info', + allowedGears: ['PT', 'OT', 'GN', 'PS', 'FC'], + }, + ZONE_III: { + code: 'ZONE_III', + i18nKey: 'zone.ZONE_III', + fallback: { ko: '특정해역 III (서남해)', en: 'Zone III (Southwest Sea)' }, + intent: 'info', + allowedGears: ['PT', 'OT', 'GN', 'PS', 'FC'], + }, + ZONE_IV: { + code: 'ZONE_IV', + i18nKey: 'zone.ZONE_IV', + fallback: { ko: '특정해역 IV (서해)', en: 'Zone IV (West Sea)' }, + intent: 'warning', + allowedGears: ['GN', 'PS', 'FC'], + }, + TERRITORIAL_SEA: { + code: 'TERRITORIAL_SEA', + i18nKey: 'zone.TERRITORIAL_SEA', + fallback: { ko: '영해', en: 'Territorial Sea' }, + intent: 'critical', + allowedGears: [], + }, + CONTIGUOUS_ZONE: { + code: 'CONTIGUOUS_ZONE', + i18nKey: 'zone.CONTIGUOUS_ZONE', + fallback: { ko: '접속수역', en: 'Contiguous Zone' }, + intent: 'high', + allowedGears: [], + }, + EEZ_OR_BEYOND: { + code: 'EEZ_OR_BEYOND', + i18nKey: 'zone.EEZ_OR_BEYOND', + fallback: { ko: 'EEZ 외', en: 'EEZ or Beyond' }, + intent: 'muted', + allowedGears: [], + }, +}; + +export function getZoneCodeIntent(code: string): BadgeIntent { + return ZONE_CODES[code as ZoneCode]?.intent ?? 'muted'; +} + +export function getZoneCodeLabel( + code: string, + t: (k: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = ZONE_CODES[code as ZoneCode]; + if (!meta) return code || '-'; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} + +export function getZoneAllowedGears(code: string): string[] { + return ZONE_CODES[code as ZoneCode]?.allowedGears ?? []; +} diff --git a/frontend/src/stores/gearReplayPreprocess.ts b/frontend/src/stores/gearReplayPreprocess.ts new file mode 100644 index 0000000..3979f69 --- /dev/null +++ b/frontend/src/stores/gearReplayPreprocess.ts @@ -0,0 +1,419 @@ +/** + * 어구 그룹 궤적 리플레이 전처리 모듈 + * + * API 데이터(history frames) → deck.gl TripsLayer 포맷으로 변환. + * iran 프로젝트의 검증된 패턴을 KCG에 맞게 재구현. + */ + +// ── 타입 ────────────────────────────────────────────────────────────── + +export interface HistoryFrame { + snapshotTime: string; + centerLat: number; + centerLon: number; + memberCount: number; + polygon: unknown; + members: FrameMember[]; + subClusterId?: number; + areaSqNm?: number; +} + +export interface FrameMember { + mmsi: string; + name?: string; + lat: number; + lon: number; + cog?: number; + sog?: number; + role?: string; + isParent?: boolean; +} + +/** TripsLayer 데이터 항목 — MMSI별 궤적 */ +export interface TripsLayerDatum { + id: string; + path: [number, number][]; + timestamps: number[]; + color: [number, number, number, number]; +} + +/** 중심 궤적 세그먼트 (PathLayer용) */ +export interface CenterTrailSegment { + path: [number, number][]; + isInterpolated: boolean; +} + +/** 멤버 보간 위치 (프레임 간 보간) */ +export interface MemberPosition { + mmsi: string; + name: string; + lon: number; + lat: number; + cog: number; + role: string; + isParent: boolean; + isGear: boolean; + stale: boolean; +} + +// ── 색상 상수 ────────────────────────────────────────────────────────── + +const PARENT_COLOR: [number, number, number, number] = [6, 182, 212, 220]; // cyan +const GEAR_COLOR: [number, number, number, number] = [245, 158, 11, 200]; // amber +const OTHER_COLOR: [number, number, number, number] = [148, 163, 184, 180]; // slate + +function memberColor(role: string, isParent: boolean): [number, number, number, number] { + if (isParent || role === 'PARENT') return PARENT_COLOR; + if (role === 'GEAR') return GEAR_COLOR; + return OTHER_COLOR; +} + +// ── 1. buildMemberTripsData ──────────────────────────────────────────── + +/** + * 프레임별 멤버를 순회하여 MMSI별 TripsLayer 데이터를 생성. + * timestamps는 startTime 기준 상대값 (TripsLayer 요구사항). + */ +export function buildMemberTripsData( + frames: HistoryFrame[], + startTime: number, +): TripsLayerDatum[] { + const byMmsi = new Map(); + + for (const frame of frames) { + const frameTime = new Date(frame.snapshotTime).getTime(); + const relativeTime = frameTime - startTime; + + for (const m of frame.members) { + if (m.lat == null || m.lon == null) continue; + + let entry = byMmsi.get(m.mmsi); + if (!entry) { + entry = { + path: [], + timestamps: [], + role: m.role ?? 'UNKNOWN', + isParent: m.isParent ?? false, + }; + byMmsi.set(m.mmsi, entry); + } + entry.path.push([m.lon, m.lat]); + entry.timestamps.push(relativeTime); + } + } + + const result: TripsLayerDatum[] = []; + for (const [mmsi, entry] of byMmsi) { + if (entry.path.length < 2) continue; // 궤적이 되려면 최소 2점 + result.push({ + id: mmsi, + path: entry.path, + timestamps: entry.timestamps, + color: memberColor(entry.role, entry.isParent), + }); + } + return result; +} + +// ── 2. buildCenterTrailData ──────────────────────────────────────────── + +/** + * 어구 그룹 중심 이동 궤적 (PathLayer용). + * 연속 프레임의 중심을 이어서 세그먼트 생성. + */ +export function buildCenterTrailData(frames: HistoryFrame[]): { + segments: CenterTrailSegment[]; + dots: [number, number][]; +} { + const dots: [number, number][] = []; + const path: [number, number][] = []; + + for (const f of frames) { + if (f.centerLon == null || f.centerLat == null) continue; + const pos: [number, number] = [f.centerLon, f.centerLat]; + dots.push(pos); + path.push(pos); + } + + // 단일 연속 세그먼트 (끊김 없음) + const segments: CenterTrailSegment[] = path.length >= 2 + ? [{ path, isInterpolated: false }] + : []; + + return { segments, dots }; +} + +// ── 3. buildSnapshotRanges ───────────────────────────────────────────── + +/** + * 프로그레스바 틱마크용 — 각 스냅샷 시점을 [0, 1] 범위로 정규화. + */ +export function buildSnapshotRanges( + frames: HistoryFrame[], + startTime: number, + endTime: number, +): number[] { + const duration = endTime - startTime; + if (duration <= 0) return []; + + return frames.map(f => { + const t = new Date(f.snapshotTime).getTime(); + return (t - startTime) / duration; + }); +} + +// ── 4. findFrameAtTime ───────────────────────────────────────────────── + +/** + * 현재 시점에 해당하는 프레임 인덱스를 찾는다. + * 커서 기반 전진 스캔(O(1) amortized) + 이진탐색 fallback. + * + * @returns { index, cursor } — index=-1이면 stale (30분 이상 이전) + */ +export function findFrameAtTime( + frameTimes: number[], + timeMs: number, + cursor: number, +): { index: number; cursor: number } { + const len = frameTimes.length; + if (len === 0) return { index: -1, cursor: 0 }; + + // 범위 밖 + if (timeMs <= frameTimes[0]) return { index: 0, cursor: 0 }; + if (timeMs >= frameTimes[len - 1]) return { index: len - 1, cursor: len - 1 }; + + // 커서 전진 스캔 (재생 중 대부분 여기서 해결) + let c = Math.min(Math.max(0, cursor), len - 1); + if (frameTimes[c] <= timeMs) { + while (c < len - 1 && frameTimes[c + 1] <= timeMs) c++; + return { index: c, cursor: c }; + } + + // fallback: 이진탐색 (seek 시) + let lo = 0; + let hi = len - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (frameTimes[mid] <= timeMs) lo = mid; + else hi = mid - 1; + } + return { index: lo, cursor: lo }; +} + +// ── 5. interpolateMemberPositions ────────────────────────────────────── + +// ── 7. computeConvexHull ─────────────────────────────────────────────── + +/** + * 멤버 위치 기반 Convex Hull 계산 (Graham scan) + 패딩. + * + * 리플레이 시 매 프레임 보간된 멤버 위치로 폴리곤을 직접 생성. + * API 폴리곤(프레임별 스냅샷)이 아닌 실시간 멤버 위치 반영. + * + * @param paddingDeg — 외곽 패딩 (도 단위, 기본 0.005° ≈ 약 500m) + */ +export function computeConvexHull( + positions: MemberPosition[], + paddingDeg = 0.005, +): [number, number][] | null { + const points: [number, number][] = positions.map(p => [p.lon, p.lat]); + if (points.length < 3) return null; + + // 가장 아래(lat 최소), 같으면 왼쪽(lon 최소) 점 찾기 + let pivot = 0; + for (let i = 1; i < points.length; i++) { + if ( + points[i][1] < points[pivot][1] || + (points[i][1] === points[pivot][1] && points[i][0] < points[pivot][0]) + ) { + pivot = i; + } + } + [points[0], points[pivot]] = [points[pivot], points[0]]; + const origin = points[0]; + + // 극각 기준 정렬 + const rest = points.slice(1).sort((a, b) => { + const angleA = Math.atan2(a[1] - origin[1], a[0] - origin[0]); + const angleB = Math.atan2(b[1] - origin[1], b[0] - origin[0]); + if (angleA !== angleB) return angleA - angleB; + // 같은 각도면 거리순 + const distA = (a[0] - origin[0]) ** 2 + (a[1] - origin[1]) ** 2; + const distB = (b[0] - origin[0]) ** 2 + (b[1] - origin[1]) ** 2; + return distA - distB; + }); + + const stack: [number, number][] = [origin]; + for (const p of rest) { + while (stack.length >= 2) { + const a = stack[stack.length - 2]; + const b = stack[stack.length - 1]; + // 외적: 반시계 방향이 아니면 제거 + const cross = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]); + if (cross > 0) break; + stack.pop(); + } + stack.push(p); + } + + if (stack.length < 3) return null; + + // 중심 계산 + let cx = 0, cy = 0; + for (const p of stack) { cx += p[0]; cy += p[1]; } + cx /= stack.length; + cy /= stack.length; + + // 패딩 적용: 각 꼭짓점을 중심에서 바깥으로 paddingDeg만큼 밀어냄 + const padded: [number, number][] = stack.map(p => { + const dx = p[0] - cx; + const dy = p[1] - cy; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist === 0) return p; + const scale = (dist + paddingDeg) / dist; + return [cx + dx * scale, cy + dy * scale]; + }); + + // GeoJSON Polygon은 첫 점 = 끝 점 (닫힌 링) + padded.push(padded[0]); + return padded; +} + +// ── 5. 멤버 메타데이터 ───────────────────────────────────────────────── + +/** 멤버 메타 정보 (name, role, isParent) — 전체 프레임에서 추출 */ +export interface MemberMeta { + name: string; + role: string; + isParent: boolean; +} + +/** + * 전체 프레임을 순회하여 멤버별 메타 정보 수집. + * 나중 프레임이 이전 프레임을 덮어씀 (최신 정보 우선). + */ +export function buildMemberMetadata( + frames: HistoryFrame[], +): Map { + const meta = new Map(); + for (const frame of frames) { + for (const m of frame.members) { + meta.set(m.mmsi, { + name: m.name ?? '', + role: m.role ?? 'UNKNOWN', + isParent: m.isParent ?? false, + }); + } + } + return meta; +} + +// ── 6. interpolateFromTripsData ──────────────────────────────────────── + +/** + * 두 좌표 사이의 heading(침로) 계산. + * iran animationStore.ts의 calculateHeading과 동일. + */ +function calcHeading(p1: [number, number], p2: [number, number]): number { + const dx = p2[0] - p1[0]; + const dy = p2[1] - p1[1]; + let angle = (Math.atan2(dx, dy) * 180) / Math.PI; + if (angle < 0) angle += 360; + return angle; +} + +/** + * iran의 getCurrentVesselPositions 패턴 — 멤버별 개별 타임라인에서 보간. + * + * 프레임 기반(frameA/frameB) 대신 멤버별 경로(memberTripsData)를 사용하여 + * 각 멤버가 24시간 내내 연속 경로로 유지. + * 커서 기반 전진 스캔(O(1)) + 이진탐색 fallback (iran과 동일). + * + * @param memberTripsData — 전처리된 멤버별 경로 (timestamps는 startTime 기준 상대값) + * @param memberMeta — 멤버 메타 정보 (name, role, isParent) + * @param relativeTimeMs — startTime 기준 상대 시간 (ms) + * @param cursors — 멤버별 커서 Map (호출 간 유지, 재생 시 O(1)) + */ +export function interpolateFromTripsData( + memberTripsData: TripsLayerDatum[], + memberMeta: Map, + relativeTimeMs: number, + cursors: Map, +): MemberPosition[] { + const positions: MemberPosition[] = []; + + for (const trip of memberTripsData) { + const { id: mmsi, path, timestamps } = trip; + if (path.length === 0) continue; + + const meta = memberMeta.get(mmsi); + const role = meta?.role ?? 'UNKNOWN'; + const base = { + mmsi, + name: meta?.name ?? '', + role, + isParent: meta?.isParent ?? false, + isGear: role === 'GEAR', + stale: false, + }; + + // 범위 밖: 처음/마지막 위치 고정 + if (relativeTimeMs <= timestamps[0]) { + positions.push({ ...base, lon: path[0][0], lat: path[0][1], cog: 0 }); + continue; + } + if (relativeTimeMs >= timestamps[timestamps.length - 1]) { + const last = path.length - 1; + const cog = last > 0 ? calcHeading(path[last - 1], path[last]) : 0; + positions.push({ ...base, lon: path[last][0], lat: path[last][1], cog }); + cursors.set(mmsi, last); + continue; + } + + // 커서 기반 탐색 (iran positionCursors 패턴) + let cursor = cursors.get(mmsi) ?? 0; + + if ( + cursor >= timestamps.length || + (cursor > 0 && timestamps[cursor - 1] > relativeTimeMs) + ) { + // 커서 무효 or 시간 역행 → 이진탐색 fallback (seek 시) + let lo = 0; + let hi = timestamps.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (timestamps[mid] <= relativeTimeMs) lo = mid; + else hi = mid - 1; + } + cursor = lo; + } else { + // 선형 전진 (재생 중 1~2칸, O(1)) + while (cursor < timestamps.length - 1 && timestamps[cursor + 1] <= relativeTimeMs) { + cursor++; + } + } + cursors.set(mmsi, cursor); + + const idx1 = cursor; + const idx2 = Math.min(cursor + 1, timestamps.length - 1); + + if (idx1 === idx2 || timestamps[idx1] === timestamps[idx2]) { + const cog = idx1 > 0 ? calcHeading(path[idx1 - 1], path[idx1]) : 0; + positions.push({ ...base, lon: path[idx1][0], lat: path[idx1][1], cog }); + } else { + // 선형 보간 (iran의 interpolatePosition과 동일) + const ratio = (relativeTimeMs - timestamps[idx1]) / (timestamps[idx2] - timestamps[idx1]); + const lon = path[idx1][0] + (path[idx2][0] - path[idx1][0]) * ratio; + const lat = path[idx1][1] + (path[idx2][1] - path[idx1][1]) * ratio; + const cog = calcHeading(path[idx1], path[idx2]); + positions.push({ ...base, lon, lat, cog }); + } + } + + return positions; +} diff --git a/frontend/src/stores/gearReplayStore.ts b/frontend/src/stores/gearReplayStore.ts new file mode 100644 index 0000000..b5d68db --- /dev/null +++ b/frontend/src/stores/gearReplayStore.ts @@ -0,0 +1,241 @@ +/** + * 어구 그룹 궤적 리플레이 스토어 + * + * loadHistory() 시 전처리를 수행하고 결과를 상태로 보관. + * rAF 루프로 currentTime을 갱신 → useGearReplayLayers 훅이 구독하여 렌더링. + */ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; +import { + buildMemberTripsData, + buildCenterTrailData, + buildSnapshotRanges, + buildMemberMetadata, + type HistoryFrame, + type TripsLayerDatum, + type CenterTrailSegment, + type MemberMeta, +} from './gearReplayPreprocess'; + +/** 24시간을 30초에 재생: SPEED_FACTOR = (24 * 3600 * 1000) / (30 * 1000) = 2880 */ +const SPEED_FACTOR = 2880; + +interface CorrelationItem { + targetMmsi: string; + targetName: string; + score: number; + freezeState: string; +} + +interface GearReplayState { + // timeline + isPlaying: boolean; + currentTime: number; + startTime: number; + endTime: number; + playbackSpeed: number; + + // data (원본) + groupKey: string | null; + historyFrames: HistoryFrame[]; + correlationItems: CorrelationItem[]; + + // 전처리 결과 + frameTimes: number[]; + memberTripsData: TripsLayerDatum[]; + memberMetadata: Map; + centerTrailSegments: CenterTrailSegment[]; + centerDotsPositions: [number, number][]; + snapshotRanges: number[]; + dataStartTime: number; + dataEndTime: number; + + // 후보 선박 항적 + candidateTripsData: TripsLayerDatum[]; + candidateMetadata: Map; + + // actions + loadHistory: ( + groupKey: string, + frames: HistoryFrame[], + correlations: CorrelationItem[], + candidateTracks?: { vesselId: string; shipName: string; geometry: [number, number][]; timestamps: string[] }[], + ) => void; + play: () => void; + pause: () => void; + seek: (time: number) => void; + setSpeed: (speed: number) => void; + reset: () => void; +} + +// Module-level rAF state — React re-render 유발 없음 +let animFrameId = 0; +let lastFrameTime = 0; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +export const useGearReplayStore = create()( + subscribeWithSelector((set, get) => ({ + // timeline + isPlaying: false, + currentTime: 0, + startTime: 0, + endTime: 0, + playbackSpeed: 1, + + // data + groupKey: null, + historyFrames: [], + correlationItems: [], + + // 전처리 결과 + frameTimes: [], + memberTripsData: [], + memberMetadata: new Map(), + centerTrailSegments: [], + centerDotsPositions: [], + snapshotRanges: [], + dataStartTime: 0, + dataEndTime: 0, + + // 후보 선박 항적 + candidateTripsData: [], + candidateMetadata: new Map(), + + loadHistory: (groupKey, frames, correlations, candidateTracks) => { + get().pause(); + + const sorted = [...frames].sort( + (a, b) => new Date(a.snapshotTime).getTime() - new Date(b.snapshotTime).getTime(), + ); + + const frameTimes = sorted.map(f => new Date(f.snapshotTime).getTime()); + const dataStartTime = frameTimes.length > 0 ? frameTimes[0] : Date.now() - 86_400_000; + const dataEndTime = frameTimes.length > 0 ? frameTimes[frameTimes.length - 1] : Date.now(); + + // 타임라인은 데이터 실제 범위 사용 + const startTime = dataStartTime; + const endTime = dataEndTime; + + // 전처리 + const memberTripsData = buildMemberTripsData(sorted, startTime); + const memberMetadata = buildMemberMetadata(sorted); + const { segments, dots } = buildCenterTrailData(sorted); + const snapshotRanges = buildSnapshotRanges(sorted, startTime, endTime); + + // 후보 선박 항적 전처리 + const candidateTripsData: TripsLayerDatum[] = []; + const candidateMetadata = new Map(); + if (candidateTracks) { + for (const track of candidateTracks) { + if (track.geometry.length < 2) continue; + candidateMetadata.set(track.vesselId, { name: track.shipName }); + // timestamps: Unix초 문자열 → startTime 기준 상대 ms + const relTs = track.timestamps.map(t => Number(t) * 1000 - startTime); + candidateTripsData.push({ + id: track.vesselId, + path: track.geometry, + timestamps: relTs, + color: [16, 185, 129, 200], // emerald (후보 선박) + }); + } + } + + set({ + groupKey, + historyFrames: sorted, + correlationItems: correlations, + frameTimes, + startTime, + endTime, + dataStartTime, + dataEndTime, + memberTripsData, memberMetadata, + centerTrailSegments: segments, + centerDotsPositions: dots, + snapshotRanges, + candidateTripsData, candidateMetadata, + currentTime: dataStartTime, + isPlaying: false, + }); + }, + + play: () => { + const state = get(); + if (state.isPlaying) return; + + const startFrom = + state.currentTime >= state.endTime ? state.startTime : state.currentTime; + + set({ isPlaying: true, currentTime: startFrom }); + lastFrameTime = performance.now(); + + const tick = (now: number) => { + const { isPlaying, currentTime, endTime, playbackSpeed } = get(); + if (!isPlaying) return; + + const delta = now - lastFrameTime; + lastFrameTime = now; + + const newTime = currentTime + delta * SPEED_FACTOR * playbackSpeed; + + if (newTime >= endTime) { + set({ currentTime: endTime, isPlaying: false }); + animFrameId = 0; + return; + } + + set({ currentTime: newTime }); + animFrameId = requestAnimationFrame(tick); + }; + + animFrameId = requestAnimationFrame(tick); + }, + + pause: () => { + if (animFrameId !== 0) { + cancelAnimationFrame(animFrameId); + animFrameId = 0; + } + set({ isPlaying: false }); + }, + + seek: (time) => { + const { startTime, endTime } = get(); + set({ currentTime: clamp(time, startTime, endTime) }); + }, + + setSpeed: (speed) => { + set({ playbackSpeed: speed }); + }, + + reset: () => { + if (animFrameId !== 0) { + cancelAnimationFrame(animFrameId); + animFrameId = 0; + } + set({ + isPlaying: false, + currentTime: 0, + startTime: 0, + endTime: 0, + playbackSpeed: 1, + groupKey: null, + historyFrames: [], + correlationItems: [], + frameTimes: [], + memberTripsData: [], + memberMetadata: new Map(), + centerTrailSegments: [], + centerDotsPositions: [], + snapshotRanges: [], + candidateTripsData: [], + candidateMetadata: new Map(), + dataStartTime: 0, + dataEndTime: 0, + }); + }, + })), +); diff --git a/prediction/algorithms/fishing_pattern.py b/prediction/algorithms/fishing_pattern.py index 64201b6..e547872 100644 --- a/prediction/algorithms/fishing_pattern.py +++ b/prediction/algorithms/fishing_pattern.py @@ -28,6 +28,9 @@ def classify_vessel_state(sog: float, cog_delta: float = 0.0, return 'TRANSIT' sog_min, sog_max = GEAR_SOG_THRESHOLDS.get(gear_type, (1.0, 5.0)) if sog_min <= sog <= sog_max: + # PT(쌍끌이): 공조 시 COG 변화가 적어야 함 — 큰 COG 변화는 비조업 + if gear_type == 'PT' and cog_delta > 30.0: + return 'UNKNOWN' return 'FISHING' return 'UNKNOWN' @@ -83,12 +86,19 @@ def detect_fishing_segments(df_vessel: pd.DataFrame, records[seg_start_idx].get('lat', 0), records[seg_start_idx].get('lon', 0), ) + seg_end = i - 1 + # Count speed anomalies within segment (SOG > TRANSIT_SOG_MIN during fishing) + speed_anomalies = 0 + for idx in range(seg_start_idx, seg_end + 1): + if records[idx].get('sog', 0) > TRANSIT_SOG_MIN: + speed_anomalies += 1 segments.append({ 'start_idx': seg_start_idx, - 'end_idx': i - 1, + 'end_idx': seg_end, 'duration_min': round(dur_min, 1), 'zone': zone_info.get('zone', 'UNKNOWN'), 'in_territorial_sea': zone_info.get('zone') == 'TERRITORIAL_SEA', + 'speed_anomaly_count': speed_anomalies, }) in_fishing = False @@ -104,12 +114,19 @@ def detect_fishing_segments(df_vessel: pd.DataFrame, records[seg_start_idx].get('lat', 0), records[seg_start_idx].get('lon', 0), ) + seg_end = len(records) - 1 + # Count speed anomalies within segment (SOG > TRANSIT_SOG_MIN during fishing) + speed_anomalies = 0 + for idx in range(seg_start_idx, seg_end + 1): + if records[idx].get('sog', 0) > TRANSIT_SOG_MIN: + speed_anomalies += 1 segments.append({ 'start_idx': seg_start_idx, - 'end_idx': len(records) - 1, + 'end_idx': seg_end, 'duration_min': round(dur_min, 1), 'zone': zone_info.get('zone', 'UNKNOWN'), 'in_territorial_sea': zone_info.get('zone') == 'TERRITORIAL_SEA', + 'speed_anomaly_count': speed_anomalies, }) return segments diff --git a/prediction/algorithms/gear_violation.py b/prediction/algorithms/gear_violation.py new file mode 100644 index 0000000..7ea2cdc --- /dev/null +++ b/prediction/algorithms/gear_violation.py @@ -0,0 +1,265 @@ +""" +어구 위반 G코드 분류 프레임워크 (DAR-03) + +G-01: 허가수역 외 조업 (zone-gear mismatch) +G-04: MMSI 조작 의심 (gear signal on/off cycling) +G-05: 어구 인위적 이동 (fixed gear drift > threshold) +G-06: 쌍끌이 공조 조업 (pair trawl — from pair_trawl.py) + +G-02 (금어기), G-03 (미등록 어구)은 외부 데이터 필요하여 보류. +""" +import math +import logging +from typing import Optional + +import pandas as pd + +logger = logging.getLogger(__name__) + +# G-code score weights +G01_SCORE = 15 # 비허가 수역 조업 +G04_SCORE = 10 # MMSI 조작 의심 +G05_SCORE = 5 # 고정어구 인위적 이동 +G06_SCORE = 20 # 쌍끌이 공조 탐지 + +# G-04 thresholds +SIGNAL_CYCLING_GAP_MIN = 30 # minutes +SIGNAL_CYCLING_MIN_COUNT = 2 + +# G-05 thresholds +GEAR_DRIFT_THRESHOLD_NM = 0.405 # ≈ 750m (보수적, 조류보정 없음) + +# Fixed gear types (stow net, gillnet, trap) +FIXED_GEAR_TYPES = {'GN', 'TRAP', 'FYK', 'FPO', 'GNS', 'GND'} + + +def _haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """두 좌표 간 거리 (해리) — Haversine 공식.""" + R = 3440.065 + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = ( + math.sin(dlat / 2) ** 2 + + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2 + ) + return 2 * R * math.asin(min(1.0, math.sqrt(a))) + + +def _detect_signal_cycling( + gear_episodes: list[dict], + threshold_min: int = SIGNAL_CYCLING_GAP_MIN, +) -> tuple[bool, int]: + """gear_identity_log 에피소드 간 간격 분석. + + 연속 에피소드 사이의 오프라인 gap이 threshold_min 이하인 횟수를 계산한다. + 인위적으로 신호를 껐다 켜는 MMSI 조작 패턴을 탐지하기 위해 사용한다. + + Args: + gear_episodes: [{'first_seen_at': datetime, 'last_seen_at': datetime}, ...] + first_seen_at 오름차순 정렬된 에피소드 목록. + threshold_min: 조작 의심 gap 상한 (분). 기본값 SIGNAL_CYCLING_GAP_MIN. + + Returns: + (is_cycling, cycling_count) 튜플. + cycling_count >= SIGNAL_CYCLING_MIN_COUNT 이면 is_cycling = True. + """ + if len(gear_episodes) < 2: + return False, 0 + + try: + sorted_episodes = sorted(gear_episodes, key=lambda e: e['first_seen_at']) + except (KeyError, TypeError) as exc: + logger.warning('gear_episodes 정렬 실패: %s', exc) + return False, 0 + + cycling_count = 0 + for i in range(1, len(sorted_episodes)): + prev_end = sorted_episodes[i - 1].get('last_seen_at') + curr_start = sorted_episodes[i].get('first_seen_at') + if prev_end is None or curr_start is None: + continue + try: + gap_sec = (curr_start - prev_end).total_seconds() + except AttributeError: + # datetime 객체가 아닌 경우 무시 + continue + gap_min = gap_sec / 60.0 + if 0 < gap_min <= threshold_min: + cycling_count += 1 + + return cycling_count >= SIGNAL_CYCLING_MIN_COUNT, cycling_count + + +def _detect_gear_drift( + positions: list[tuple[float, float]], + threshold_nm: float = GEAR_DRIFT_THRESHOLD_NM, +) -> dict: + """연속 위치 간 haversine 거리 합산으로 고정어구 이동 탐지. + + 조류에 의한 자연 이동은 보정하지 않으며, 보수적 임계값(0.405NM ≈ 750m)으로 + 인위적 이동만 탐지한다. + + Args: + positions: [(lat, lon), ...] 순서 보장된 위치 목록. + threshold_nm: 이동 탐지 임계값 (해리). 기본값 GEAR_DRIFT_THRESHOLD_NM. + + Returns: + { + 'drift_detected': bool, + 'drift_nm': float, # 총 누적 이동 거리 (해리) + 'tidal_corrected': bool, # 조류 보정 여부 (현재 항상 False) + } + """ + if len(positions) < 2: + return {'drift_detected': False, 'drift_nm': 0.0, 'tidal_corrected': False} + + total_drift_nm = 0.0 + for i in range(1, len(positions)): + lat1, lon1 = positions[i - 1] + lat2, lon2 = positions[i] + try: + total_drift_nm += _haversine_nm(lat1, lon1, lat2, lon2) + except (TypeError, ValueError) as exc: + logger.warning('gear drift 거리 계산 실패 (index %d): %s', i, exc) + continue + + drift_nm = round(total_drift_nm, 4) + return { + 'drift_detected': drift_nm > threshold_nm, + 'drift_nm': drift_nm, + 'tidal_corrected': False, + } + + +def classify_gear_violations( + mmsi: str, + gear_type: str, + zone_info: dict, + df_vessel: Optional[pd.DataFrame], + pair_result: Optional[dict], + is_permitted: bool, + gear_episodes: Optional[list[dict]] = None, + gear_positions: Optional[list[tuple[float, float]]] = None, +) -> dict: + """어구 위반 G코드 분류 메인 함수 (DAR-03). + + DAR-03 규격에 따라 G-01/G-04/G-05/G-06 위반 코드를 평가하고 + 종합 점수 및 판정 결과를 반환한다. + + Args: + mmsi: 선박 MMSI 식별자. + gear_type: 어구 유형 코드 (예: 'GN', 'PS', 'PT'). + zone_info: 수역 정보 dict. classify_zone() 반환 형식과 동일. + - 'zone': str (예: 'ZONE_I', 'TERRITORIAL_SEA') + - 'allowed_gears': list[str] (허가 어구 목록, ZONE_* 에만 존재) + df_vessel: 선박 AIS 이력 DataFrame (현재 내부 사용 없음, 향후 확장 대비). + pair_result: pair_trawl 알고리즘 결과 dict. + - 'pair_detected': bool + - 'sync_duration_min': float + - 'mean_separation_nm': float + - 'pair_mmsi': str + - 'g_codes': list[str] (선택) + is_permitted: 선박의 해역 입어 허가 여부 (현재 참조 전용, G-01과 독립 판정). + gear_episodes: 어구 신호 에피소드 목록. G-04 평가에 사용. + [{'first_seen_at': datetime, 'last_seen_at': datetime}, ...] + gear_positions: 어구 위치 목록 [(lat, lon), ...]. G-05 평가에 사용. + + Returns: + { + 'g_codes': list[str], # 탐지된 G코드 목록 (예: ['G-01', 'G-06']) + 'gear_judgment': str, # 최고 우선순위 판정 레이블 + 'evidence': dict, # G코드별 근거 데이터 + 'gear_violation_score': int, # 위반 점수 합계 + } + + 판정 우선순위: ZONE_VIOLATION > PAIR_TRAWL > GEAR_MISMATCH > '' (정상) + """ + g_codes: list[str] = [] + evidence: dict = {} + score = 0 + judgment = '' + + # ── G-01: 허가수역 외 조업 ───────────────────────────────────── + zone = zone_info.get('zone', '') + if zone.startswith('ZONE_'): + allowed_gears: list[str] = zone_info.get('allowed_gears', []) + if allowed_gears and gear_type not in allowed_gears: + g_codes.append('G-01') + score += G01_SCORE + evidence['G-01'] = { + 'zone': zone, + 'gear': gear_type, + 'allowed': allowed_gears, + } + judgment = 'ZONE_VIOLATION' + logger.debug( + 'G-01 탐지 [mmsi=%s] zone=%s gear=%s allowed=%s', + mmsi, zone, gear_type, allowed_gears, + ) + + # ── G-04: MMSI 조작 의심 (고정어구 신호 on/off 반복) ─────────── + if gear_episodes is not None and gear_type in FIXED_GEAR_TYPES: + try: + is_cycling, cycling_count = _detect_signal_cycling(gear_episodes) + except Exception as exc: + logger.error('G-04 평가 실패 [mmsi=%s]: %s', mmsi, exc) + is_cycling, cycling_count = False, 0 + + if is_cycling: + g_codes.append('G-04') + score += G04_SCORE + evidence['G-04'] = { + 'cycling_count': cycling_count, + 'threshold_min': SIGNAL_CYCLING_GAP_MIN, + } + if not judgment: + judgment = 'GEAR_MISMATCH' + logger.debug( + 'G-04 탐지 [mmsi=%s] cycling_count=%d', mmsi, cycling_count, + ) + + # ── G-05: 고정어구 인위적 이동 ──────────────────────────────── + if gear_positions is not None and gear_type in FIXED_GEAR_TYPES: + try: + drift_result = _detect_gear_drift(gear_positions) + except Exception as exc: + logger.error('G-05 평가 실패 [mmsi=%s]: %s', mmsi, exc) + drift_result = {'drift_detected': False, 'drift_nm': 0.0, 'tidal_corrected': False} + + if drift_result['drift_detected']: + g_codes.append('G-05') + score += G05_SCORE + evidence['G-05'] = drift_result + if not judgment: + judgment = 'GEAR_MISMATCH' + logger.debug( + 'G-05 탐지 [mmsi=%s] drift_nm=%.4f', mmsi, drift_result['drift_nm'], + ) + + # ── G-06: 쌍끌이 공조 조업 ──────────────────────────────────── + if pair_result and pair_result.get('pair_detected'): + g_codes.append('G-06') + score += G06_SCORE + evidence['G-06'] = { + 'sync_duration_min': pair_result.get('sync_duration_min'), + 'mean_separation_nm': pair_result.get('mean_separation_nm'), + 'pair_mmsi': pair_result.get('pair_mmsi'), + } + # pair_result 내 추가 g_codes (예: 'P-01') 병합 + extra_codes: list[str] = pair_result.get('g_codes', []) + for code in extra_codes: + if code not in g_codes: + g_codes.append(code) + if not judgment: + judgment = 'PAIR_TRAWL' + logger.debug( + 'G-06 탐지 [mmsi=%s] pair_mmsi=%s sync_min=%s', + mmsi, pair_result.get('pair_mmsi'), pair_result.get('sync_duration_min'), + ) + + return { + 'g_codes': g_codes, + 'gear_judgment': judgment, + 'evidence': evidence, + 'gear_violation_score': score, + } diff --git a/prediction/algorithms/pair_trawl.py b/prediction/algorithms/pair_trawl.py new file mode 100644 index 0000000..472d746 --- /dev/null +++ b/prediction/algorithms/pair_trawl.py @@ -0,0 +1,383 @@ +"""쌍끌이 트롤 공조 탐지 — DAR-03 G-06. + +두 선박의 AIS 궤적을 분석하여 쌍끌이 조업 여부를 판정한다. +판정 기준 (DAR-03 / Kroodsma 2018): +- 선박 간격 ≤ 500m (0.27 NM) +- 속력 차이 ≤ 0.5 kn +- 방향 차이 ≤ 10° +- 조업 속력 2.0~4.0 kn +- 지속 시간 ≥ 2시간 +- 동시 AIS 차단 ≥ 30분 → P-01 추가 +""" + +from __future__ import annotations + +import logging +import math + +import pandas as pd + +try: + from algorithms.location import haversine_nm +except ImportError: + def haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float: # type: ignore[misc] + """두 좌표 간 거리 (해리). fallback 구현.""" + R = 3440.065 + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = (math.sin(dlat / 2) ** 2 + + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) + * math.sin(dlon / 2) ** 2) + return 2 * R * math.asin(math.sqrt(a)) + +logger = logging.getLogger(__name__) + +# ────────────────────────────────────────────────────────────── +# 상수 +# ────────────────────────────────────────────────────────────── + +PROXIMITY_NM = 0.27 # 500m ≈ 0.27 NM +SOG_DELTA_MAX = 0.5 # kn +COG_DELTA_MAX = 10.0 # degrees +SOG_MIN = 2.0 # kn (조업 속력 하한) +SOG_MAX = 4.0 # kn (조업 속력 상한) +MIN_SYNC_CYCLES = 24 # 24 × 5min = 2시간 +SIMULTANEOUS_GAP_MIN = 30 # 동시 AIS 차단 기준 (분) +CYCLE_INTERVAL_MIN = 5 # 5분 리샘플 데이터 + +# scan_unregistered_pairs 전용 +CELL_SIZE = 0.01 # ~1.1km 격자 +CANDIDATE_PROXIMITY_FACTOR = 2.0 # 후보 탐색 반경: PROXIMITY_NM × 2 +CANDIDATE_SOG_MIN = 1.5 # 후보 속력 하한 (완화) +CANDIDATE_SOG_MAX = 5.0 # 후보 속력 상한 (완화) + +# ────────────────────────────────────────────────────────────── +# 내부 헬퍼 +# ────────────────────────────────────────────────────────────── + +def _cog_delta(cog_a: float, cog_b: float) -> float: + """두 COG 값의 각도 차이 (0~180°).""" + return abs((cog_a - cog_b + 180.0) % 360.0 - 180.0) + + +def _find_gaps(df: pd.DataFrame, gap_threshold_min: float = 10.0) -> list[tuple[pd.Timestamp, pd.Timestamp]]: + """DataFrame 행 간 gap_threshold_min 초과 gap 구간 목록 반환. + + Returns: + list of (gap_start, gap_end) as pd.Timestamp pairs + """ + if len(df) < 2: + return [] + ts_series = pd.to_datetime(df['timestamp']).sort_values().reset_index(drop=True) + gaps: list[tuple[pd.Timestamp, pd.Timestamp]] = [] + for i in range(1, len(ts_series)): + delta_min = (ts_series.iloc[i] - ts_series.iloc[i - 1]).total_seconds() / 60.0 + if delta_min > gap_threshold_min: + gaps.append((ts_series.iloc[i - 1], ts_series.iloc[i])) + return gaps + + +def _overlap_minutes( + gaps_a: list[tuple[pd.Timestamp, pd.Timestamp]], + gaps_b: list[tuple[pd.Timestamp, pd.Timestamp]], +) -> float: + """두 gap 목록의 시간 구간 겹침 합계 (분).""" + total = 0.0 + for start_a, end_a in gaps_a: + for start_b, end_b in gaps_b: + overlap_start = max(start_a, start_b) + overlap_end = min(end_a, end_b) + if overlap_end > overlap_start: + total += (overlap_end - overlap_start).total_seconds() / 60.0 + return total + + +def _max_sync_block(synced_series: 'pd.Series[bool]') -> int: + """연속 True 블록의 최대 길이 반환.""" + max_block = 0 + current = 0 + for val in synced_series: + if val: + current += 1 + max_block = max(max_block, current) + else: + current = 0 + return max_block + + +def _cell_key(lat: float, lon: float) -> tuple[int, int]: + return (round(lat / CELL_SIZE), round(lon / CELL_SIZE)) + + +def _default_result(mmsi_b: str) -> dict: + return { + 'pair_detected': False, + 'sync_duration_min': 0.0, + 'max_sync_block_min': 0.0, + 'mean_separation_nm': 0.0, + 'sog_delta_mean': 0.0, + 'cog_delta_mean': 0.0, + 'simultaneous_gap_min': 0.0, + 'g_codes': [], + 'confidence': 0.0, + 'pair_mmsi': mmsi_b, + } + + +# ────────────────────────────────────────────────────────────── +# 공개 API +# ────────────────────────────────────────────────────────────── + +def detect_pair_trawl( + df_a: pd.DataFrame, + df_b: pd.DataFrame, + mmsi_a: str, + mmsi_b: str, +) -> dict: + """쌍끌이 트롤 공조 탐지 (DAR-03 G-06). + + Args: + df_a: 선박 A의 AIS DataFrame. 필수 컬럼: timestamp, lat, lon, sog, cog + df_b: 선박 B의 AIS DataFrame. 필수 컬럼: timestamp, lat, lon, sog, cog + mmsi_a: 선박 A MMSI + mmsi_b: 선박 B MMSI (결과의 pair_mmsi에 기록) + + Returns: + { + 'pair_detected': bool, + 'sync_duration_min': float, + 'max_sync_block_min': float, + 'mean_separation_nm': float, + 'sog_delta_mean': float, + 'cog_delta_mean': float, + 'simultaneous_gap_min': float, + 'g_codes': list[str], + 'confidence': float, + 'pair_mmsi': str, + } + """ + required_cols = {'timestamp', 'lat', 'lon', 'sog', 'cog'} + + if df_a.empty or df_b.empty: + logger.debug('pair_trawl(%s, %s): empty DataFrame', mmsi_a, mmsi_b) + return _default_result(mmsi_b) + + missing_a = required_cols - set(df_a.columns) + missing_b = required_cols - set(df_b.columns) + if missing_a or missing_b: + logger.warning( + 'pair_trawl(%s, %s): missing columns a=%s b=%s', + mmsi_a, mmsi_b, missing_a, missing_b, + ) + return _default_result(mmsi_b) + + # ── Step 1: timestamp inner join ──────────────────────── + a = df_a[['timestamp', 'lat', 'lon', 'sog', 'cog']].copy() + b = df_b[['timestamp', 'lat', 'lon', 'sog', 'cog']].copy() + + a['timestamp'] = pd.to_datetime(a['timestamp']) + b['timestamp'] = pd.to_datetime(b['timestamp']) + + merged = pd.merge( + a.rename(columns={'lat': 'lat_a', 'lon': 'lon_a', 'sog': 'sog_a', 'cog': 'cog_a'}), + b.rename(columns={'lat': 'lat_b', 'lon': 'lon_b', 'sog': 'sog_b', 'cog': 'cog_b'}), + on='timestamp', + how='inner', + ).sort_values('timestamp').reset_index(drop=True) + + total_aligned = len(merged) + if total_aligned < MIN_SYNC_CYCLES: + logger.debug( + 'pair_trawl(%s, %s): only %d aligned rows (need %d)', + mmsi_a, mmsi_b, total_aligned, MIN_SYNC_CYCLES, + ) + return _default_result(mmsi_b) + + # ── Step 2: 행별 동기화 지표 계산 ─────────────────────── + merged['distance_nm'] = merged.apply( + lambda r: haversine_nm(r['lat_a'], r['lon_a'], r['lat_b'], r['lon_b']), + axis=1, + ) + merged['sog_delta'] = (merged['sog_a'] - merged['sog_b']).abs() + merged['cog_delta'] = merged.apply( + lambda r: _cog_delta(r['cog_a'], r['cog_b']), + axis=1, + ) + merged['both_in_range'] = ( + merged['sog_a'].between(SOG_MIN, SOG_MAX) + & merged['sog_b'].between(SOG_MIN, SOG_MAX) + ) + merged['synced'] = ( + (merged['distance_nm'] <= PROXIMITY_NM) + & (merged['sog_delta'] <= SOG_DELTA_MAX) + & (merged['cog_delta'] <= COG_DELTA_MAX) + & merged['both_in_range'] + ) + + # ── Step 3: 연속 블록 탐지 ────────────────────────────── + max_block_cycles = _max_sync_block(merged['synced']) + if max_block_cycles < MIN_SYNC_CYCLES: + logger.debug( + 'pair_trawl(%s, %s): max sync block %d cycles < %d required', + mmsi_a, mmsi_b, max_block_cycles, MIN_SYNC_CYCLES, + ) + return _default_result(mmsi_b) + + total_synced = int(merged['synced'].sum()) + sync_duration_min = total_synced * CYCLE_INTERVAL_MIN + max_sync_block_min = max_block_cycles * CYCLE_INTERVAL_MIN + + mean_separation_nm = float(merged.loc[merged['synced'], 'distance_nm'].mean()) + sog_delta_mean = float(merged.loc[merged['synced'], 'sog_delta'].mean()) + cog_delta_mean = float(merged.loc[merged['synced'], 'cog_delta'].mean()) + + # ── Step 4: 동시 AIS 차단 검출 ────────────────────────── + gaps_a = _find_gaps(df_a, gap_threshold_min=10.0) + gaps_b = _find_gaps(df_b, gap_threshold_min=10.0) + simultaneous_gap_min = _overlap_minutes(gaps_a, gaps_b) + + g_codes: list[str] = [] + if simultaneous_gap_min >= SIMULTANEOUS_GAP_MIN: + g_codes.append('P-01') + + # ── Step 5: 신뢰도 산출 ───────────────────────────────── + sync_ratio = min(1.0, total_synced / total_aligned) + + synced_distances = merged.loc[merged['synced'], 'distance_nm'] + if len(synced_distances) > 1: + std_distance = float(synced_distances.std()) + else: + std_distance = 0.0 + separation_stability = 1.0 - min(1.0, std_distance / PROXIMITY_NM) + + sog_sync_quality = 1.0 - min(1.0, sog_delta_mean / SOG_DELTA_MAX) + + confidence = round( + sync_ratio * 0.4 + + separation_stability * 0.3 + + sog_sync_quality * 0.3, + 4, + ) + + logger.info( + 'pair_trawl(%s, %s): detected — sync=%.0fmin max_block=%.0fmin ' + 'sep=%.3fnm confidence=%.3f g_codes=%s', + mmsi_a, mmsi_b, + sync_duration_min, max_sync_block_min, + mean_separation_nm, confidence, g_codes, + ) + + return { + 'pair_detected': True, + 'sync_duration_min': round(sync_duration_min, 1), + 'max_sync_block_min': round(max_sync_block_min, 1), + 'mean_separation_nm': round(mean_separation_nm, 4), + 'sog_delta_mean': round(sog_delta_mean, 4), + 'cog_delta_mean': round(cog_delta_mean, 4), + 'simultaneous_gap_min': round(simultaneous_gap_min, 1), + 'g_codes': g_codes, + 'confidence': confidence, + 'pair_mmsi': mmsi_b, + } + + +def scan_unregistered_pairs( + vessel_dfs: dict[str, pd.DataFrame], + registered_pairs: set[tuple[str, str]], +) -> list[tuple[str, str]]: + """fleet_registry에 없는 TRAWL 선박 중 500m 이내 + 속력 동기화 조건을 + 만족하는 쌍 후보 반환. cell-key partitioning으로 O(n²) 회피. + + Args: + vessel_dfs: mmsi → AIS DataFrame. 각 DataFrame은 timestamp, lat, lon, sog 컬럼 필요 + registered_pairs: 이미 확인된 쌍 (fleet_tracker 제공). 정규화: (smaller, larger) + + Returns: + list of (mmsi_a, mmsi_b) candidate pairs (정규화된 순서) + """ + if len(vessel_dfs) < 2: + return [] + + CANDIDATE_PROXIMITY_NM = PROXIMITY_NM * CANDIDATE_PROXIMITY_FACTOR + + # ── 각 선박의 마지막 위치 추출 ────────────────────────── + last_positions: dict[str, dict] = {} + for mmsi, df in vessel_dfs.items(): + if df.empty or 'lat' not in df.columns or 'lon' not in df.columns or 'sog' not in df.columns: + continue + try: + last_row = df.sort_values('timestamp').iloc[-1] + lat = float(last_row['lat']) + lon = float(last_row['lon']) + sog = float(last_row['sog']) + except Exception: + continue + last_positions[mmsi] = {'lat': lat, 'lon': lon, 'sog': sog} + + if len(last_positions) < 2: + return [] + + # ── cell-key 격자 구성 ─────────────────────────────────── + cell_map: dict[tuple[int, int], list[str]] = {} + for mmsi, pos in last_positions.items(): + key = _cell_key(pos['lat'], pos['lon']) + if key not in cell_map: + cell_map[key] = [] + cell_map[key].append(mmsi) + + # ── 인접 9셀 내 후보 쌍 탐색 ──────────────────────────── + candidates: list[tuple[str, str]] = [] + checked: set[tuple[str, str]] = set() + + for mmsi_a, pos_a in last_positions.items(): + base_cell = _cell_key(pos_a['lat'], pos_a['lon']) + + # 인접 8셀 + 자기 셀 수집 + neighbor_mmsis: list[str] = [] + for dr in (-1, 0, 1): + for dc in (-1, 0, 1): + neighbor_cell = (base_cell[0] + dr, base_cell[1] + dc) + neighbor_mmsis.extend(cell_map.get(neighbor_cell, [])) + + for mmsi_b in neighbor_mmsis: + if mmsi_b == mmsi_a: + continue + + # 정규화된 쌍 키 + pair_key: tuple[str, str] = ( + (mmsi_a, mmsi_b) if mmsi_a < mmsi_b else (mmsi_b, mmsi_a) + ) + + # 중복 검사 + if pair_key in checked: + continue + checked.add(pair_key) + + # 이미 등록된 쌍 건너뜀 + if pair_key in registered_pairs: + continue + + pos_b = last_positions.get(mmsi_b) + if pos_b is None: + continue + + # 속력 범위 필터 (완화 기준) + sog_a = pos_a['sog'] + sog_b = pos_b['sog'] + if not (CANDIDATE_SOG_MIN <= sog_a <= CANDIDATE_SOG_MAX): + continue + if not (CANDIDATE_SOG_MIN <= sog_b <= CANDIDATE_SOG_MAX): + continue + + # 거리 필터 + dist_nm = haversine_nm(pos_a['lat'], pos_a['lon'], pos_b['lat'], pos_b['lon']) + if dist_nm > CANDIDATE_PROXIMITY_NM: + continue + + candidates.append(pair_key) + + logger.debug( + 'scan_unregistered_pairs: %d vessels, %d candidates found', + len(last_positions), len(candidates), + ) + return candidates diff --git a/prediction/db/kcgdb.py b/prediction/db/kcgdb.py index 8d61838..cb0fc21 100644 --- a/prediction/db/kcgdb.py +++ b/prediction/db/kcgdb.py @@ -78,7 +78,8 @@ def upsert_results(results: list['AnalysisResult']) -> int: fleet_cluster_id, fleet_is_leader, fleet_role, risk_score, risk_level, transship_suspect, transship_pair_mmsi, transship_duration_min, - features + features, + gear_judgment ) VALUES %s ON CONFLICT (mmsi, analyzed_at) DO UPDATE SET vessel_type = EXCLUDED.vessel_type, @@ -104,7 +105,8 @@ def upsert_results(results: list['AnalysisResult']) -> int: transship_suspect = EXCLUDED.transship_suspect, transship_pair_mmsi = EXCLUDED.transship_pair_mmsi, transship_duration_min = EXCLUDED.transship_duration_min, - features = EXCLUDED.features + features = EXCLUDED.features, + gear_judgment = EXCLUDED.gear_judgment """ try: diff --git a/prediction/fleet_tracker.py b/prediction/fleet_tracker.py index ba4f959..5f54bc6 100644 --- a/prediction/fleet_tracker.py +++ b/prediction/fleet_tracker.py @@ -83,6 +83,26 @@ class FleetTracker: len(self._vessels), ) + def get_pair_mmsi(self, mmsi: str) -> Optional[str]: + """PT 쌍끌이 쌍대 선박의 MMSI를 반환. 없으면 None.""" + vid = self._mmsi_to_vid.get(mmsi) + if vid is None: + return None + pair_vid = self._vessels.get(vid, {}).get('pair_vessel_id') + if pair_vid is None: + return None + pair_vessel = self._vessels.get(pair_vid) + if pair_vessel is None: + return None + return pair_vessel.get('mmsi') + + def get_vessel_gear_code(self, mmsi: str) -> Optional[str]: + """등록 선박의 어구 코드(C21=PT, C22=OT 등)를 반환.""" + vid = self._mmsi_to_vid.get(mmsi) + if vid is None: + return None + return self._vessels.get(vid, {}).get('gear_code') + def match_ais_to_registry(self, ais_vessels: list[dict], conn) -> None: """AIS 선박을 등록 선단에 매칭. DB 업데이트. diff --git a/prediction/main.py b/prediction/main.py index d75236f..9b36f02 100644 --- a/prediction/main.py +++ b/prediction/main.py @@ -161,3 +161,64 @@ def get_correlation_tracks( except Exception as e: logger.warning('get_correlation_tracks failed for %s: %s', group_key, e) return {'groupKey': group_key, 'vessels': []} + + +GROUP_POLYGON_SNAPSHOTS = qualified_table('group_polygon_snapshots') + + +@app.get('/api/v1/groups/{group_key:path}/history') +def get_group_history(group_key: str, hours: int = 24): + """그룹 폴리곤 스냅샷 24h 히스토리 (리플레이용).""" + import json as _json + + try: + with kcgdb.get_conn() as conn: + cur = conn.cursor() + cur.execute(f""" + SELECT snapshot_time, + ST_Y(center_point) AS center_lat, + ST_X(center_point) AS center_lon, + member_count, + ST_AsGeoJSON(polygon)::text AS polygon_geojson, + members::text AS members_json, + sub_cluster_id, + area_sq_nm + FROM {GROUP_POLYGON_SNAPSHOTS} + WHERE group_key = %s + AND snapshot_time > NOW() - (%s * INTERVAL '1 hour') + ORDER BY snapshot_time ASC + """, (group_key, hours)) + + frames = [] + for row in cur.fetchall(): + polygon = None + if row[4]: + try: + polygon = _json.loads(row[4]) + except Exception: + pass + members = [] + if row[5]: + try: + members = _json.loads(row[5]) + except Exception: + pass + frames.append({ + 'snapshotTime': row[0].isoformat() if row[0] else None, + 'centerLat': float(row[1]) if row[1] else None, + 'centerLon': float(row[2]) if row[2] else None, + 'memberCount': int(row[3]) if row[3] else 0, + 'polygon': polygon, + 'members': members, + 'subClusterId': int(row[6]) if row[6] else 0, + 'areaSqNm': float(row[7]) if row[7] else 0, + }) + cur.close() + + logger.info('group history: group_key=%r, hours=%d, frames=%d', + group_key, hours, len(frames)) + return {'groupKey': group_key, 'frameCount': len(frames), 'frames': frames} + + except Exception as e: + logger.warning('get_group_history failed for %s: %s', group_key, e) + return {'groupKey': group_key, 'frameCount': 0, 'frames': []} diff --git a/prediction/models/result.py b/prediction/models/result.py index ae439e7..53c0883 100644 --- a/prediction/models/result.py +++ b/prediction/models/result.py @@ -5,7 +5,7 @@ from typing import Optional @dataclass class AnalysisResult: - """vessel_analysis_results 테이블 28컬럼 매핑.""" + """vessel_analysis_results 테이블 29컬럼 매핑.""" mmsi: str timestamp: datetime @@ -52,6 +52,9 @@ class AnalysisResult: # 특징 벡터 features: dict = field(default_factory=dict) + # ALGO 09: 어구 위반 판정 + gear_judgment: str = '' + # 메타 analyzed_at: Optional[datetime] = None @@ -116,4 +119,5 @@ class AnalysisResult: str(self.transship_pair_mmsi), _i(self.transship_duration_min), json.dumps(safe_features), + str(self.gear_judgment) if self.gear_judgment else None, ) diff --git a/prediction/output/event_generator.py b/prediction/output/event_generator.py index 3b1f5d0..52787c3 100644 --- a/prediction/output/event_generator.py +++ b/prediction/output/event_generator.py @@ -140,6 +140,41 @@ RULES = [ 'category': 'HIGH_RISK_VESSEL', 'title_fn': lambda r: f"위험 행동 패턴 (위험도 {r.get('risk_score', 0)})", }, + # ── G-code 어구 위반 규칙 ── + { + 'name': 'g06_pair_trawl', + 'condition': lambda r: 'G-06' in ((r.get('features') or {}).get('g_codes') or []), + 'level': 'CRITICAL', + 'category': 'GEAR_ILLEGAL', + 'title_fn': lambda r: ( + f"쌍끌이 불법조업 의심 (G-06): " + f"{((r.get('features') or {}).get('gear_violation_evidence') or {}).get('G-06', {}).get('sync_duration_min', 0):.0f}분 공조" + ), + }, + { + 'name': 'g01_zone_gear_violation', + 'condition': lambda r: 'G-01' in ((r.get('features') or {}).get('g_codes') or []), + 'level': 'HIGH', + 'category': 'GEAR_ILLEGAL', + 'title_fn': lambda r: ( + f"수역-어구 위반 (G-01): " + f"{r.get('vessel_type', '')} 비허가 수역 조업" + ), + }, + { + 'name': 'g04_mmsi_cycling', + 'condition': lambda r: 'G-04' in ((r.get('features') or {}).get('g_codes') or []), + 'level': 'HIGH', + 'category': 'MMSI_TAMPERING', + 'title_fn': lambda r: "어구 MMSI 조작 의심 (G-04): 신호 주기적 단속", + }, + { + 'name': 'g05_gear_drift', + 'condition': lambda r: 'G-05' in ((r.get('features') or {}).get('g_codes') or []), + 'level': 'MEDIUM', + 'category': 'GEAR_ILLEGAL', + 'title_fn': lambda r: "어구 인위적 이동 의심 (G-05): 조류보정 초과 이동", + }, ] diff --git a/prediction/output/violation_classifier.py b/prediction/output/violation_classifier.py index d8f2e90..ac4f730 100644 --- a/prediction/output/violation_classifier.py +++ b/prediction/output/violation_classifier.py @@ -60,7 +60,7 @@ def classify_violations(result: dict) -> list[str]: # 어구 불법 (gear_judgment이 있는 경우만 — 현재는 scheduler에서 채우지 않음) gear_judgment = result.get('gear_judgment', '') or '' - if gear_judgment in ('NO_PERMIT', 'GEAR_MISMATCH', 'ZONE_VIOLATION', 'SEASON_VIOLATION'): + if gear_judgment in ('NO_PERMIT', 'GEAR_MISMATCH', 'ZONE_VIOLATION', 'SEASON_VIOLATION', 'PAIR_TRAWL'): violations.append('ILLEGAL_GEAR') # 위험 행동 (다른 위반 없이 고위험) diff --git a/prediction/scheduler.py b/prediction/scheduler.py index 7f6070d..8d382c4 100644 --- a/prediction/scheduler.py +++ b/prediction/scheduler.py @@ -225,8 +225,12 @@ def run_analysis_cycle(): zone_info = classify_zone(last_row['lat'], last_row['lon']) - gear_map = {'TRAWL': 'OT', 'PURSE': 'PS', 'LONGLINE': 'GN', 'TRAP': 'TRAP'} - gear = gear_map.get(c['vessel_type'], 'OT') + gear_map = {'TRAWL': 'OT', 'PT': 'PT', 'PURSE': 'PS', 'LONGLINE': 'GN', 'TRAP': 'TRAP'} + # fleet_registry gear_code C21(쌍끌이) → vessel_type 'PT' 오버라이드 + vtype = c['vessel_type'] + if hasattr(fleet_tracker, 'get_vessel_gear_code') and fleet_tracker.get_vessel_gear_code(mmsi) == 'C21': + vtype = 'PT' + gear = gear_map.get(vtype, 'OT') ucaf = compute_ucaf_score(df_v, gear) ucft = compute_ucft_score(df_v) @@ -287,12 +291,62 @@ def run_analysis_cycle(): if 'state' in df_v.columns and len(df_v) > 0: activity = df_v['state'].mode().iloc[0] - merged_features = {**(c.get('features', {}) or {}), **dark_features} + # ── G-01 수역-어구 불일치 체크 ── + g_codes: list[str] = [] + gear_judgment = '' + zone_code = zone_info.get('zone', 'EEZ_OR_BEYOND') + allowed_gears = zone_info.get('allowed_gears', []) + if zone_code.startswith('ZONE_') and allowed_gears and gear not in allowed_gears: + g_codes.append('G-01') + gear_judgment = 'ZONE_VIOLATION' + + # pair_trawl 결과 병합 (Phase 2에서 pair_results dict 채워짐) + pair_result = pair_results.get(mmsi) if 'pair_results' in dir() else None + if pair_result and pair_result.get('pair_detected'): + g_codes.extend(pair_result.get('g_codes', [])) + if not gear_judgment: + gear_judgment = 'PAIR_TRAWL' + + gear_violation_score = 0 + gear_violation_evidence: dict = {} + if 'G-01' in g_codes: + gear_violation_score += 15 + gear_violation_evidence['G-01'] = { + 'zone': zone_code, 'gear': gear, + 'allowed': allowed_gears, + } + if 'G-06' in g_codes and pair_result: + gear_violation_score += 20 + gear_violation_evidence['G-06'] = { + 'sync_duration_min': pair_result.get('sync_duration_min', 0), + 'mean_separation_nm': pair_result.get('mean_separation_nm', 0), + 'pair_mmsi': pair_result.get('pair_mmsi', ''), + } + + # risk_score에 어구 위반 가산 + final_risk = min(100, risk_score + gear_violation_score) + final_risk_level = risk_level + if final_risk >= 70: + final_risk_level = 'CRITICAL' + elif final_risk >= 50: + final_risk_level = 'HIGH' + elif final_risk >= 30: + final_risk_level = 'MEDIUM' + + merged_features = { + **(c.get('features', {}) or {}), + **dark_features, + 'g_codes': g_codes, + 'gear_violation_score': gear_violation_score, + 'gear_violation_evidence': gear_violation_evidence, + 'pair_trawl_detected': bool(pair_result and pair_result.get('pair_detected')), + 'pair_trawl_pair_mmsi': (pair_result or {}).get('pair_mmsi', ''), + } results.append(AnalysisResult( mmsi=mmsi, timestamp=ts, - vessel_type=c['vessel_type'], + vessel_type=vtype, confidence=c['confidence'], fishing_pct=c['fishing_pct'], cluster_id=fleet_info.get('cluster_id', -1), @@ -310,9 +364,10 @@ def run_analysis_cycle(): cluster_size=fleet_info.get('cluster_size', 0), is_leader=fleet_info.get('is_leader', False), fleet_role=fleet_info.get('fleet_role', 'NOISE'), - risk_score=risk_score, - risk_level=risk_level, + risk_score=final_risk, + risk_level=final_risk_level, features=merged_features, + gear_judgment=gear_judgment, )) logger.info( diff --git a/prediction/scripts/diagnostic-snapshot.sh b/prediction/scripts/diagnostic-snapshot.sh index 8de47ce..96fe03f 100644 --- a/prediction/scripts/diagnostic-snapshot.sh +++ b/prediction/scripts/diagnostic-snapshot.sh @@ -1,7 +1,7 @@ #!/bin/bash # prediction 알고리즘 진단 스냅샷 수집기 (5분 주기, 수동 종료까지 연속 실행) # -# 용도: 알고리즘 재설계 후 동작 검증용. 단순 집계가 아닌 개별 판정 과정 추적. +# 용도: DAR-03 G코드 + 쌍끌이 + 어구 위반 포함 알고리즘 동작 검증 # 실행: nohup bash /home/apps/kcg-ai-prediction/scripts/diagnostic-snapshot.sh & # 종료: kill $(cat /home/apps/kcg-ai-prediction/data/diag/diag.pid) # 출력: /home/apps/kcg-ai-prediction/data/diag/YYYYMMDD-HHMM.txt @@ -25,7 +25,7 @@ OUT="$OUTDIR/$STAMP.txt" { echo "###################################################################" -echo "# PREDICTION DIAGNOSTIC SNAPSHOT" +echo "# PREDICTION DIAGNOSTIC SNAPSHOT (DAR-03 enhanced)" echo "# generated: $(date '+%Y-%m-%d %H:%M:%S %Z')" echo "# host: $(hostname)" echo "# interval: ${INTERVAL_SEC}s" @@ -45,6 +45,7 @@ SELECT count(*) total, count(*) FILTER (WHERE vessel_type = 'UNKNOWN') lightweight, count(*) FILTER (WHERE is_dark) dark, count(*) FILTER (WHERE transship_suspect) transship, + count(*) FILTER (WHERE gear_judgment IS NOT NULL AND gear_judgment != '') gear_violation, count(*) FILTER (WHERE risk_level='CRITICAL') crit, count(*) FILTER (WHERE risk_level='HIGH') high, round(avg(risk_score)::numeric, 1) avg_risk, @@ -83,7 +84,7 @@ GROUP BY bucket ORDER BY bucket; SQL echo "" -echo "--- 2-2. dark_patterns 발동 빈도 (어떤 규칙이 얼마나 적용되는지) ---" +echo "--- 2-2. dark_patterns 발동 빈도 ---" $PSQL_TABLE << 'SQL' SELECT pattern, count(*) cnt, @@ -97,72 +98,24 @@ GROUP BY pattern ORDER BY cnt DESC; SQL echo "" -echo "--- 2-3. P9 선종별 dark 분포 (신규 패턴 검증) ---" +echo "--- 2-3. P9/P10/P11 + coverage 요약 ---" $PSQL_TABLE << 'SQL' -SELECT - CASE WHEN features->>'dark_patterns' LIKE '%fishing_vessel_dark%' THEN 'FISHING(+10)' - WHEN features->>'dark_patterns' LIKE '%cargo_natural_gap%' THEN 'CARGO(-10)' - ELSE 'NO_KIND_EFFECT' END AS p9_effect, - count(*) cnt, - round(avg((features->>'dark_suspicion_score')::int)::numeric, 1) avg_score, - round(avg(gap_duration_min)::numeric, 0) avg_gap -FROM kcg.vessel_analysis_results -WHERE analyzed_at > now() - interval '5 minutes' - AND is_dark = true -GROUP BY p9_effect ORDER BY cnt DESC; +WITH dark AS ( + SELECT features FROM kcg.vessel_analysis_results + WHERE analyzed_at > now() - interval '5 minutes' AND is_dark = true +) +SELECT count(*) total_dark, + count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%fishing_vessel_dark%' + OR features->>'dark_patterns' LIKE '%cargo_natural_gap%') p9, + count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%underway_deliberate_off%' + OR features->>'dark_patterns' LIKE '%anchored_natural_gap%') p10, + count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%heading_cog_mismatch%') p11, + count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%out_of_coverage%') coverage +FROM dark; SQL echo "" -echo "--- 2-4. P10 항해상태 dark 분포 (신규 패턴 검증) ---" -$PSQL_TABLE << 'SQL' -SELECT - CASE WHEN features->>'dark_patterns' LIKE '%underway_deliberate_off%' THEN 'UNDERWAY_OFF(+20)' - WHEN features->>'dark_patterns' LIKE '%anchored_natural_gap%' THEN 'ANCHORED(-15)' - ELSE 'NO_STATUS_EFFECT' END AS p10_effect, - count(*) cnt, - round(avg((features->>'dark_suspicion_score')::int)::numeric, 1) avg_score -FROM kcg.vessel_analysis_results -WHERE analyzed_at > now() - interval '5 minutes' - AND is_dark = true -GROUP BY p10_effect ORDER BY cnt DESC; -SQL - -echo "" -echo "--- 2-5. P11 heading/COG 불일치 (신규 패턴 검증) ---" -$PSQL_TABLE << 'SQL' -SELECT - CASE WHEN features->>'dark_patterns' LIKE '%heading_cog_mismatch%' THEN 'MISMATCH(+15)' - ELSE 'NO_MISMATCH' END AS p11_effect, - count(*) cnt, - round(avg((features->>'dark_suspicion_score')::int)::numeric, 1) avg_score -FROM kcg.vessel_analysis_results -WHERE analyzed_at > now() - interval '5 minutes' - AND is_dark = true -GROUP BY p11_effect ORDER BY cnt DESC; -SQL - -echo "" -echo "--- 2-6. GAP 구간별 dark_tier 교차표 (임계값 100분 검증) ---" -$PSQL_TABLE << 'SQL' -SELECT CASE - WHEN gap_duration_min < 100 THEN 'a_lt100 (NOT_DARK 예상)' - WHEN gap_duration_min < 180 THEN 'b_100-179' - WHEN gap_duration_min < 360 THEN 'c_180-359' - WHEN gap_duration_min < 720 THEN 'd_360-719' - ELSE 'e_gte720' END gap_bucket, - count(*) total, - count(*) FILTER (WHERE is_dark) dark, - count(*) FILTER (WHERE features->>'dark_tier' = 'CRITICAL') crit, - count(*) FILTER (WHERE features->>'dark_tier' = 'HIGH') high, - count(*) FILTER (WHERE features->>'dark_tier' = 'WATCH') watch, - count(*) FILTER (WHERE features->>'dark_tier' = 'NONE') tier_none -FROM kcg.vessel_analysis_results -WHERE analyzed_at > now() - interval '5 minutes' -GROUP BY gap_bucket ORDER BY gap_bucket; -SQL - -echo "" -echo "--- 2-7. CRITICAL dark 상위 10건 (개별 판정 상세) ---" +echo "--- 2-4. CRITICAL dark 상위 10건 ---" $PSQL_TABLE << 'SQL' SELECT mmsi, gap_duration_min, zone_code, activity_state, (features->>'dark_suspicion_score')::int AS score, @@ -177,59 +130,152 @@ LIMIT 10; SQL #=================================================================== -# PART 3: 환적 탐지 심층 진단 +# PART 3: 환적 탐지 #=================================================================== echo "" echo "=================================================================" -echo "PART 3: TRANSSHIPMENT 심층 진단" +echo "PART 3: TRANSSHIPMENT 진단" echo "=================================================================" -echo "" -echo "--- 3-1. 환적 의심 건수 + 점수 분포 ---" $PSQL_TABLE << 'SQL' -SELECT count(*) total_suspects, +SELECT count(*) suspects, count(*) FILTER (WHERE (features->>'transship_score')::numeric >= 70) critical, count(*) FILTER (WHERE (features->>'transship_score')::numeric >= 50 AND (features->>'transship_score')::numeric < 70) high, round(avg((features->>'transship_score')::numeric)::numeric, 1) avg_score, - round(avg(transship_duration_min)::numeric, 0) avg_duration_min + round(avg(transship_duration_min)::numeric, 0) avg_dur FROM kcg.vessel_analysis_results -WHERE analyzed_at > now() - interval '5 minutes' - AND transship_suspect = true; +WHERE analyzed_at > now() - interval '5 minutes' AND transship_suspect = true; SQL echo "" -echo "--- 3-2. 환적 의심 개별 건 상세 (전체) ---" +echo "--- 3-1. 환적 의심 개별 건 ---" $PSQL_TABLE << 'SQL' -SELECT mmsi, transship_pair_mmsi AS pair_mmsi, - transship_duration_min AS dur_min, - (features->>'transship_score')::numeric AS score, - features->>'transship_tier' AS tier, - zone_code, - activity_state, - risk_score +SELECT mmsi, transship_pair_mmsi pair, transship_duration_min dur, + (features->>'transship_score')::numeric score, + features->>'transship_tier' tier, zone_code FROM kcg.vessel_analysis_results -WHERE analyzed_at > now() - interval '5 minutes' - AND transship_suspect = true +WHERE analyzed_at > now() - interval '5 minutes' AND transship_suspect = true ORDER BY (features->>'transship_score')::numeric DESC; SQL -echo "" -echo "--- 3-3. 환적 후보 선종 분포 (Stage 1 이종 쌍 검증) ---" -echo " (이 쿼리는 journalctl 로그에서 추출)" -journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \ - grep -o 'transshipment:.*' | tail -1 - #=================================================================== -# PART 4: 이벤트 + KPI +# PART 4: G코드 어구 위반 진단 (DAR-03 신규) #=================================================================== echo "" echo "=================================================================" -echo "PART 4: 이벤트 + KPI (시스템 출력 검증)" +echo "PART 4: G코드 어구 위반 진단 (DAR-03)" echo "=================================================================" echo "" -echo "--- 4-1. prediction_events (last 5min) ---" +echo "--- 4-1. gear_judgment 분포 ---" +$PSQL_TABLE << 'SQL' +SELECT coalesce(NULLIF(gear_judgment, ''), '(none)') AS judgment, + count(*) cnt, + round(avg(risk_score)::numeric, 1) avg_risk +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' +GROUP BY judgment ORDER BY cnt DESC; +SQL + +echo "" +echo "--- 4-2. G코드별 발동 빈도 ---" +$PSQL_TABLE << 'SQL' +SELECT gcode, + count(*) cnt, + round(avg(risk_score)::numeric, 1) avg_risk +FROM kcg.vessel_analysis_results, + LATERAL jsonb_array_elements_text(features->'g_codes') AS gcode +WHERE analyzed_at > now() - interval '5 minutes' +GROUP BY gcode ORDER BY cnt DESC; +SQL + +echo "" +echo "--- 4-3. G-01 수역-어구 위반 상세 (상위 20건) ---" +$PSQL_TABLE << 'SQL' +SELECT mmsi, zone_code, vessel_type, risk_score, gear_judgment, + (features->>'gear_violation_score')::int AS gv_score, + (features->'gear_violation_evidence'->'G-01'->>'allowed')::text AS allowed +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' + AND features->>'g_codes' LIKE '%G-01%' +ORDER BY risk_score DESC LIMIT 20; +SQL + +echo "" +echo "--- 4-4. G-06 쌍끌이 공조 탐지 ---" +$PSQL_TABLE << 'SQL' +SELECT mmsi, zone_code, vessel_type, risk_score, + (features->'gear_violation_evidence'->'G-06'->>'sync_duration_min') sync_min, + (features->'gear_violation_evidence'->'G-06'->>'mean_separation_nm') sep_nm, + (features->'gear_violation_evidence'->'G-06'->>'pair_mmsi') pair_mmsi, + features->>'pair_trawl_detected' pt +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' + AND (features->>'pair_trawl_detected' = 'true' OR features->>'g_codes' LIKE '%G-06%') +ORDER BY risk_score DESC LIMIT 20; +SQL + +echo "" +echo "--- 4-5. G-04 MMSI 조작 + G-05 어구 이동 ---" +$PSQL_TABLE << 'SQL' +SELECT mmsi, zone_code, vessel_type, risk_score, + features->>'g_codes' g_codes, + (features->'gear_violation_evidence'->'G-04'->>'cycling_count') g04_cycle, + (features->'gear_violation_evidence'->'G-05'->>'drift_nm') g05_drift +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' + AND (features->>'g_codes' LIKE '%G-04%' OR features->>'g_codes' LIKE '%G-05%') +ORDER BY risk_score DESC LIMIT 10; +SQL + +echo "" +echo "--- 4-6. GEAR_ILLEGAL 이벤트 ---" +$PSQL_TABLE << 'SQL' +SELECT category, level, title, count(*) cnt +FROM kcg.prediction_events +WHERE created_at > now() - interval '5 minutes' + AND category IN ('GEAR_ILLEGAL', 'MMSI_TAMPERING') +GROUP BY category, level, title ORDER BY cnt DESC; +SQL + +echo "" +echo "--- 4-7. violation_categories ILLEGAL_GEAR ---" +$PSQL_TABLE << 'SQL' +SELECT count(*) total, + count(*) FILTER (WHERE gear_judgment = 'ZONE_VIOLATION') zone_viol, + count(*) FILTER (WHERE gear_judgment = 'PAIR_TRAWL') pair_trawl, + count(*) FILTER (WHERE gear_judgment = 'GEAR_MISMATCH') mismatch +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' + AND 'ILLEGAL_GEAR' = ANY(violation_categories); +SQL + +#=================================================================== +# PART 5: 수역 × 어구 타입 교차 (G-01 검증 핵심) +#=================================================================== +echo "" +echo "=================================================================" +echo "PART 5: 수역 x 어구 타입 교차 (G-01 검증)" +echo "=================================================================" + +$PSQL_TABLE << 'SQL' +SELECT zone_code, vessel_type, count(*) total, + count(*) FILTER (WHERE features->>'g_codes' LIKE '%G-01%') g01 +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '5 minutes' + AND vessel_type != 'UNKNOWN' AND zone_code LIKE 'ZONE_%' +GROUP BY zone_code, vessel_type ORDER BY zone_code, vessel_type; +SQL + +#=================================================================== +# PART 6: 이벤트 + KPI +#=================================================================== +echo "" +echo "=================================================================" +echo "PART 6: 이벤트 + KPI" +echo "=================================================================" + $PSQL_TABLE << 'SQL' SELECT category, level, count(*) cnt FROM kcg.prediction_events @@ -237,77 +283,37 @@ WHERE created_at > now() - interval '5 minutes' GROUP BY category, level ORDER BY cnt DESC; SQL -echo "" -echo "--- 4-2. KPI 실시간 ---" $PSQL_TABLE << 'SQL' SELECT kpi_key, value, trend, delta_pct, updated_at FROM kcg.prediction_kpi_realtime ORDER BY kpi_key; SQL #=================================================================== -# PART 5: signal-batch 정적정보 보강 검증 +# PART 7: 사이클 로그 + 에러 #=================================================================== echo "" echo "=================================================================" -echo "PART 5: signal-batch 정적정보 보강 검증" -echo "=================================================================" - -echo "" -echo "--- 5-1. 직전 사이클 enrich 로그 ---" -journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \ - grep -E 'signal-batch enrich|fetch_recent_detail' | tail -2 - -echo "" -echo "--- 5-2. features 내 신규 패턴(P9/P10/P11) 적용 비율 ---" -$PSQL_TABLE << 'SQL' -WITH dark_vessels AS ( - SELECT features FROM kcg.vessel_analysis_results - WHERE analyzed_at > now() - interval '5 minutes' AND is_dark = true -) -SELECT - count(*) AS total_dark, - count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%fishing_vessel_dark%' - OR features->>'dark_patterns' LIKE '%cargo_natural_gap%') AS p9_applied, - count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%underway_deliberate_off%' - OR features->>'dark_patterns' LIKE '%anchored_natural_gap%') AS p10_applied, - count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%heading_cog_mismatch%') AS p11_applied, - round(count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%fishing_vessel_dark%' - OR features->>'dark_patterns' LIKE '%cargo_natural_gap%')::numeric - / NULLIF(count(*), 0) * 100, 1) AS p9_pct, - round(count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%underway_deliberate_off%' - OR features->>'dark_patterns' LIKE '%anchored_natural_gap%')::numeric - / NULLIF(count(*), 0) * 100, 1) AS p10_pct, - round(count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%heading_cog_mismatch%')::numeric - / NULLIF(count(*), 0) * 100, 1) AS p11_pct -FROM dark_vessels; -SQL - -#=================================================================== -# PART 6: 사이클 로그 (직전 6분) -#=================================================================== -echo "" -echo "=================================================================" -echo "PART 6: 사이클 로그 (최근 6분)" +echo "PART 7: 사이클 로그 (최근 6분)" echo "=================================================================" journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \ - grep -E 'analysis cycle:|lightweight analysis:|pipeline dark:|event_generator:|kpi_writer:|stats_aggregator|enrich|transship|ERROR|Traceback' | \ + grep -E 'analysis cycle:|lightweight|pipeline dark:|event_generator:|pair_trawl|gear_violation|GEAR_ILLEGAL|ERROR|Traceback' | \ tail -20 #=================================================================== -# PART 7: 해역별 + 위험도 교차 (운영 지표) +# PART 8: 해역별 종합 교차 #=================================================================== echo "" echo "=================================================================" -echo "PART 7: 해역별 × 위험도 교차표" +echo "PART 8: 해역별 종합 교차표" echo "=================================================================" $PSQL_TABLE << 'SQL' -SELECT zone_code, - count(*) total, +SELECT zone_code, count(*) total, count(*) FILTER (WHERE is_dark) dark, + count(*) FILTER (WHERE transship_suspect) transship, + count(*) FILTER (WHERE gear_judgment IS NOT NULL AND gear_judgment != '') gear_viol, count(*) FILTER (WHERE risk_level='CRITICAL') crit, count(*) FILTER (WHERE risk_level='HIGH') high, - round(avg(risk_score)::numeric, 1) avg_risk, - count(*) FILTER (WHERE transship_suspect) transship + round(avg(risk_score)::numeric, 1) avg_risk FROM kcg.vessel_analysis_results WHERE analyzed_at > now() - interval '5 minutes' GROUP BY zone_code ORDER BY total DESC; diff --git a/prediction/scripts/hourly-analysis-snapshot.sh b/prediction/scripts/hourly-analysis-snapshot.sh index 8bf863d..d7c5efa 100755 --- a/prediction/scripts/hourly-analysis-snapshot.sh +++ b/prediction/scripts/hourly-analysis-snapshot.sh @@ -1,20 +1,9 @@ #!/bin/bash -# prediction 시간당 상태 스냅샷 수집기 +# prediction 시간당 상태 스냅샷 수집기 (DAR-03 G코드 + 어구 위반 포함) # 실행 환경: redis-211 서버 (prediction 서비스 호스트) # cron: 0 * * * * /home/apps/kcg-ai-prediction/scripts/hourly-analysis-snapshot.sh # # 출력: /home/apps/kcg-ai-prediction/data/hourly-analysis/YYYYMMDD-HHMM.txt -# 수집 대상: -# 1. vessel_analysis_results 전체 분포 (pipeline vs lightweight, dark/spoof/risk) -# 2. zone_code 분포 + dark 교차 집계 -# 3. dark vessel gap_duration_min 분포 -# 4. dark vessel activity_state 분포 -# 5. dark vessel 상세 샘플 20건 (mmsi/zone/gap/lat/lon) -# 6. prediction_events 카테고리×level 분포 -# 7. prediction_stats_hourly 최근 2건 -# 8. prediction_kpi_realtime 전체 -# 9. risk_score 히스토그램 -# 10. 직전 1시간 사이클 로그 (journalctl) set -u @@ -27,7 +16,7 @@ export PGPASSWORD=Kcg2026ai PSQL="psql -U kcg-app -d kcgaidb -h 211.208.115.83 -P pager=off" { -echo "# prediction hourly snapshot" +echo "# prediction hourly snapshot (DAR-03 enhanced)" echo "# generated: $(date '+%Y-%m-%d %H:%M:%S %Z')" echo "# host: $(hostname)" echo "" @@ -39,21 +28,22 @@ SELECT count(*) total, count(*) FILTER (WHERE vessel_type = 'UNKNOWN') lightweight_path, count(*) FILTER (WHERE is_dark) dark, count(*) FILTER (WHERE spoofing_score > 0.5) spoof_hi, - count(*) FILTER (WHERE spoofing_score > 0) spoof_any, - count(*) FILTER (WHERE risk_score >= 70) crit_score, + count(*) FILTER (WHERE transship_suspect) transship, + count(*) FILTER (WHERE gear_judgment IS NOT NULL AND gear_judgment != '') gear_violation, count(*) FILTER (WHERE risk_level='CRITICAL') crit_lvl, count(*) FILTER (WHERE risk_level='HIGH') high_lvl, max(risk_score) max_risk, - round(avg(risk_score)::numeric, 2) avg_risk, - count(*) FILTER (WHERE transship_suspect) transship + round(avg(risk_score)::numeric, 2) avg_risk FROM kcg.vessel_analysis_results WHERE analyzed_at > now() - interval '1 hour'; \echo -\echo === 2. ZONE × DARK distribution === +\echo === 2. ZONE x DARK x GEAR_VIOLATION distribution === SELECT zone_code, count(*) total, count(*) FILTER (WHERE is_dark) dark, + count(*) FILTER (WHERE transship_suspect) transship, + count(*) FILTER (WHERE gear_judgment IS NOT NULL AND gear_judgment != '') gear_viol, count(*) FILTER (WHERE risk_score >= 70) crit, round(avg(risk_score)::numeric, 1) avg_risk FROM kcg.vessel_analysis_results @@ -61,7 +51,7 @@ WHERE analyzed_at > now() - interval '1 hour' GROUP BY zone_code ORDER BY total DESC; \echo -\echo === 3. DARK GAP distribution (all vessels in last 1h) === +\echo === 3. DARK GAP distribution === SELECT CASE WHEN gap_duration_min < 30 THEN 'a_lt30' WHEN gap_duration_min < 60 THEN 'b_30-59' @@ -71,21 +61,21 @@ SELECT CASE ELSE 'f_gte1440' END gap_bucket, count(*) total, count(*) FILTER (WHERE is_dark) dark, - count(*) FILTER (WHERE is_dark AND vessel_type='UNKNOWN') dark_lightweight, + count(*) FILTER (WHERE is_dark AND vessel_type='UNKNOWN') dark_lw, count(*) FILTER (WHERE is_dark AND vessel_type!='UNKNOWN') dark_pipeline FROM kcg.vessel_analysis_results WHERE analyzed_at > now() - interval '1 hour' GROUP BY gap_bucket ORDER BY gap_bucket; \echo -\echo === 4. DARK vessels by activity_state === -SELECT activity_state, count(*), round(avg(gap_duration_min)::numeric, 0) avg_gap_min +\echo === 4. DARK by activity_state === +SELECT activity_state, count(*), round(avg(gap_duration_min)::numeric, 0) avg_gap FROM kcg.vessel_analysis_results WHERE analyzed_at > now() - interval '1 hour' AND is_dark GROUP BY activity_state ORDER BY count DESC; \echo -\echo === 5. DARK sample top 20 by gap (mmsi/zone/gap/state) === +\echo === 5. DARK sample top 20 by gap === SELECT mmsi, zone_code, activity_state, gap_duration_min, risk_score FROM ( SELECT DISTINCT ON (mmsi) mmsi, zone_code, activity_state, gap_duration_min, @@ -93,11 +83,10 @@ FROM ( FROM kcg.vessel_analysis_results WHERE analyzed_at > now() - interval '1 hour' AND is_dark ORDER BY mmsi, analyzed_at DESC -) latest -ORDER BY gap_duration_min DESC LIMIT 20; +) latest ORDER BY gap_duration_min DESC LIMIT 20; \echo -\echo === 6. PREDICTION_EVENTS last 1h by category×level === +\echo === 6. EVENTS last 1h by category x level === SELECT category, level, count(*) cnt FROM kcg.prediction_events WHERE created_at > now() - interval '1 hour' @@ -116,7 +105,7 @@ SELECT kpi_key, value, trend, delta_pct, updated_at FROM kcg.prediction_kpi_realtime ORDER BY kpi_key; \echo -\echo === 9. RISK_SCORE histogram (last 1h) === +\echo === 9. RISK_SCORE histogram === SELECT CASE WHEN risk_score < 10 THEN 'a_0-9' WHEN risk_score < 30 THEN 'b_10-29' @@ -131,43 +120,19 @@ WHERE analyzed_at > now() - interval '1 hour' GROUP BY bucket ORDER BY bucket; \echo -\echo === 10. TRANSSHIP, SPOOFING, FLEET 요약 === +\echo === 10. TRANSSHIP + SPOOF + FLEET === SELECT count(*) FILTER (WHERE transship_suspect) transship_ct, count(*) FILTER (WHERE spoofing_score > 0.7) spoof_gt070, - count(*) FILTER (WHERE spoofing_score > 0.5 AND spoofing_score <= 0.7) spoof_050_070, count(*) FILTER (WHERE speed_jump_count > 0) speed_jumps, count(*) FILTER (WHERE fleet_is_leader) fleet_leader, - count(DISTINCT fleet_cluster_id) FILTER (WHERE fleet_cluster_id IS NOT NULL AND fleet_cluster_id > 0) fleet_clusters + count(DISTINCT fleet_cluster_id) FILTER (WHERE fleet_cluster_id > 0) fleet_clusters FROM kcg.vessel_analysis_results WHERE analyzed_at > now() - interval '1 hour'; \echo -\echo === 10-1. FLEET_ROLE distribution === -SELECT fleet_role, count(*), count(DISTINCT mmsi) uniq_mmsi -FROM kcg.vessel_analysis_results -WHERE analyzed_at > now() - interval '1 hour' -GROUP BY fleet_role ORDER BY count DESC; - -\echo -\echo === 10-2. TRANSSHIPMENT duration histogram === -SELECT CASE - WHEN transship_duration_min < 5 THEN 'a_0-4' - WHEN transship_duration_min < 15 THEN 'b_5-14' - WHEN transship_duration_min < 30 THEN 'c_15-29' - WHEN transship_duration_min < 60 THEN 'd_30-59' - WHEN transship_duration_min < 120 THEN 'e_60-119' - ELSE 'f_gte120' END bucket, count(*) -FROM kcg.vessel_analysis_results -WHERE analyzed_at > now() - interval '1 hour' AND transship_suspect -GROUP BY bucket ORDER BY bucket; - -\echo -\echo === G1. PIPELINE vessel_type (어구 타입) distribution === -SELECT vessel_type, - count(*), - count(*) FILTER (WHERE fishing_pct > 50) active_fishing, - round(avg(fishing_pct)::numeric, 1) avg_fish_pct, +\echo === G1. PIPELINE vessel_type distribution === +SELECT vessel_type, count(*), round(avg(ucaf_score)::numeric, 3) avg_ucaf, round(avg(ucft_score)::numeric, 3) avg_ucft, round(avg(risk_score)::numeric, 1) avg_risk @@ -176,42 +141,16 @@ WHERE analyzed_at > now() - interval '1 hour' GROUP BY vessel_type ORDER BY count DESC; \echo -\echo === G2. ACTIVITY_STATE distribution (전체) === -SELECT activity_state, count(*), - count(*) FILTER (WHERE vessel_type != 'UNKNOWN') pipeline, - round(avg(risk_score)::numeric, 1) avg_risk -FROM kcg.vessel_analysis_results -WHERE analyzed_at > now() - interval '1 hour' -GROUP BY activity_state ORDER BY count DESC; - -\echo -\echo === G3. GEAR_GROUP_PARENT_RESOLUTION status + confidence === +\echo === G2. GEAR_GROUP_PARENT_RESOLUTION === SELECT status, count(*), round(avg(confidence)::numeric, 3) avg_conf, round(avg(top_score)::numeric, 3) avg_top, - round(avg(score_margin)::numeric, 3) avg_margin, round(avg(stable_cycles)::numeric, 1) avg_stable FROM kcg.gear_group_parent_resolution GROUP BY status ORDER BY count DESC; \echo -\echo === G3-1. PARENT_RESOLUTION decision_source === -SELECT coalesce(decision_source, '(null)') ds, status, count(*) -FROM kcg.gear_group_parent_resolution -GROUP BY ds, status ORDER BY count DESC LIMIT 20; - -\echo -\echo === G4. GEAR_GROUP_EPISODES (active) === -SELECT status, continuity_source, count(*), - round(avg(continuity_score)::numeric, 3) avg_cont, - round(avg(current_member_count)::numeric, 1) avg_members, - round(avg(EXTRACT(EPOCH FROM (now() - first_seen_at))/3600)::numeric, 1) avg_age_h -FROM kcg.gear_group_episodes -WHERE last_seen_at > now() - interval '24 hours' -GROUP BY status, continuity_source ORDER BY count DESC; - -\echo -\echo === G5. GEAR_CORRELATION_SCORES (current_score) 분포 === +\echo === G3. GEAR_CORRELATION_SCORES distribution === SELECT CASE WHEN current_score < 0.3 THEN 'a_lt0.3' WHEN current_score < 0.5 THEN 'b_0.3-0.5' @@ -220,117 +159,119 @@ SELECT CASE ELSE 'e_gte0.85' END bucket, count(*), count(DISTINCT group_key) uniq_groups, - count(DISTINCT target_mmsi) uniq_targets, - round(avg(streak_count)::numeric, 1) avg_streak + count(DISTINCT target_mmsi) uniq_targets FROM kcg.gear_correlation_scores WHERE updated_at > now() - interval '1 hour' GROUP BY bucket ORDER BY bucket; \echo -\echo === G5-1. CORRELATION freeze_state === -SELECT freeze_state, count(*), round(avg(current_score)::numeric, 3) avg_score -FROM kcg.gear_correlation_scores -WHERE updated_at > now() - interval '1 hour' -GROUP BY freeze_state ORDER BY count DESC; +\echo =================================================================== +\echo === DAR-03 G-CODE DIAGNOSTICS (last 1h) +\echo =================================================================== \echo -\echo === G6. GROUP_POLYGON_SNAPSHOTS (last 1h, by type × zone) === -SELECT group_type, - coalesce(zone_id, '(null)') zone, - count(*), - round(avg(area_sq_nm)::numeric, 2) avg_area_nm, - round(avg(member_count)::numeric, 1) avg_members -FROM kcg.group_polygon_snapshots -WHERE snapshot_time > now() - interval '1 hour' -GROUP BY group_type, zone_id ORDER BY count DESC LIMIT 20; - -\echo -\echo === G7. IS_PERMITTED breakdown (lightweight path 기준) === -SELECT - count(*) FILTER (WHERE vessel_type != 'UNKNOWN') pipeline_ct, - count(*) FILTER (WHERE vessel_type = 'UNKNOWN') lightweight_ct, - count(DISTINCT mmsi) FILTER (WHERE risk_score >= 20) risk_gte20_uniq, - count(DISTINCT mmsi) FILTER (WHERE risk_score >= 50) risk_gte50_uniq, - count(DISTINCT mmsi) FILTER (WHERE risk_score >= 70) risk_gte70_uniq +\echo === D1. gear_judgment distribution === +SELECT coalesce(NULLIF(gear_judgment, ''), '(none)') judgment, + count(*) cnt, + round(avg(risk_score)::numeric, 1) avg_risk FROM kcg.vessel_analysis_results -WHERE analyzed_at > now() - interval '1 hour'; +WHERE analyzed_at > now() - interval '1 hour' +GROUP BY judgment ORDER BY cnt DESC; \echo -\echo === G8. VIOLATION_CATEGORIES (last 1h, unnest) === -SELECT unnest(violation_categories) vcat, count(*) +\echo === D2. G-code frequency === +SELECT gcode, count(*) cnt, + round(avg(risk_score)::numeric, 1) avg_risk +FROM kcg.vessel_analysis_results, + LATERAL jsonb_array_elements_text(features->'g_codes') AS gcode +WHERE analyzed_at > now() - interval '1 hour' +GROUP BY gcode ORDER BY cnt DESC; + +\echo +\echo === D3. G-01 zone x gear cross-table (VIOLATION CHECK) === +SELECT zone_code, vessel_type, count(*) total, + count(*) FILTER (WHERE features->>'g_codes' LIKE '%G-01%') g01_violation FROM kcg.vessel_analysis_results -WHERE analyzed_at > now() - interval '1 hour' AND violation_categories IS NOT NULL -GROUP BY vcat ORDER BY count DESC LIMIT 20; +WHERE analyzed_at > now() - interval '1 hour' + AND vessel_type != 'UNKNOWN' AND zone_code LIKE 'ZONE_%' +GROUP BY zone_code, vessel_type ORDER BY zone_code, vessel_type; \echo -\echo === G9. PREDICTION_EVENTS 24h hourly trend (KST) === +\echo === D4. G-06 pair trawl detections === +SELECT mmsi, zone_code, vessel_type, risk_score, + (features->'gear_violation_evidence'->'G-06'->>'sync_duration_min') sync_min, + (features->'gear_violation_evidence'->'G-06'->>'mean_separation_nm') sep_nm, + (features->'gear_violation_evidence'->'G-06'->>'pair_mmsi') pair_mmsi, + features->>'pair_trawl_detected' pt +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '1 hour' + AND (features->>'pair_trawl_detected' = 'true' OR features->>'g_codes' LIKE '%G-06%') +ORDER BY risk_score DESC LIMIT 20; + +\echo +\echo === D5. G-04 MMSI tampering + G-05 gear drift === +SELECT mmsi, zone_code, vessel_type, risk_score, + features->>'g_codes' g_codes, + (features->'gear_violation_evidence'->'G-04'->>'cycling_count') g04_cycle, + (features->'gear_violation_evidence'->'G-05'->>'drift_nm') g05_drift +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '1 hour' + AND (features->>'g_codes' LIKE '%G-04%' OR features->>'g_codes' LIKE '%G-05%') +ORDER BY risk_score DESC LIMIT 20; + +\echo +\echo === D6. GEAR_ILLEGAL events (last 1h) === +SELECT category, level, title, count(*) cnt +FROM kcg.prediction_events +WHERE created_at > now() - interval '1 hour' + AND category IN ('GEAR_ILLEGAL', 'MMSI_TAMPERING') +GROUP BY category, level, title ORDER BY cnt DESC; + +\echo +\echo === D7. violation_categories ILLEGAL_GEAR breakdown === +SELECT count(*) total, + count(*) FILTER (WHERE gear_judgment = 'ZONE_VIOLATION') zone_viol, + count(*) FILTER (WHERE gear_judgment = 'PAIR_TRAWL') pair_trawl, + count(*) FILTER (WHERE gear_judgment = 'GEAR_MISMATCH') mismatch +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '1 hour' + AND 'ILLEGAL_GEAR' = ANY(violation_categories); + +\echo +\echo === D8. gear_violation_score histogram (pipeline vessels) === +SELECT CASE + WHEN (features->>'gear_violation_score')::int = 0 THEN 'a_0 (no violation)' + WHEN (features->>'gear_violation_score')::int <= 15 THEN 'b_1-15 (G-01 or G-04/G-05)' + WHEN (features->>'gear_violation_score')::int <= 25 THEN 'c_16-25 (G-06 or combo)' + ELSE 'd_gt25 (multiple G-codes)' END bucket, + count(*) cnt, + round(avg(risk_score)::numeric, 1) avg_risk +FROM kcg.vessel_analysis_results +WHERE analyzed_at > now() - interval '1 hour' + AND vessel_type != 'UNKNOWN' + AND features->>'gear_violation_score' IS NOT NULL +GROUP BY bucket ORDER BY bucket; + +\echo +\echo === D9. EVENTS 24h hourly trend (with GEAR_ILLEGAL) === SELECT date_trunc('hour', occurred_at AT TIME ZONE 'Asia/Seoul') hr, count(*) tot, count(*) FILTER (WHERE category='DARK_VESSEL') dark, count(*) FILTER (WHERE category='ILLEGAL_TRANSSHIP') transship, count(*) FILTER (WHERE category='EEZ_INTRUSION') eez, + count(*) FILTER (WHERE category='GEAR_ILLEGAL') gear_illegal, count(*) FILTER (WHERE category='HIGH_RISK_VESSEL') high_risk, - count(*) FILTER (WHERE category='ZONE_DEPARTURE') zone_dep, count(*) FILTER (WHERE level='CRITICAL') critical FROM kcg.prediction_events WHERE created_at > now() - interval '24 hours' GROUP BY hr ORDER BY hr DESC LIMIT 25; -\echo -\echo === G10. PREDICTION_ALERTS (last 1h) === -SELECT channel, delivery_status, count(*), - round(avg(ai_confidence)::numeric, 3) avg_conf -FROM kcg.prediction_alerts -WHERE sent_at > now() - interval '1 hour' -GROUP BY channel, delivery_status ORDER BY count DESC; - SQL -echo "" -echo "=== 11. DARK SAMPLE latest position (snpdb t_vessel_tracks_5min) ===" -# Cross-database 불가 → 두 단계: kcgaidb에서 mmsi 추출 → snpdb에 별도 쿼리 -DARK_MMSIS=$(PGPASSWORD=Kcg2026ai psql -U kcg-app -d kcgaidb -h 211.208.115.83 -tA -c " -SELECT string_agg(quote_literal(mmsi), ',') -FROM (SELECT DISTINCT ON (mmsi) mmsi, gap_duration_min, analyzed_at - FROM kcg.vessel_analysis_results - WHERE analyzed_at > now() - interval '1 hour' AND is_dark - ORDER BY mmsi, analyzed_at DESC) v -WHERE v.mmsi IN ( - SELECT mmsi FROM (SELECT DISTINCT ON (mmsi) mmsi, gap_duration_min, analyzed_at - FROM kcg.vessel_analysis_results - WHERE analyzed_at > now() - interval '1 hour' AND is_dark - ORDER BY mmsi, analyzed_at DESC) x - ORDER BY gap_duration_min DESC LIMIT 20 -);" 2>/dev/null) - -if [ -n "$DARK_MMSIS" ]; then - PGPASSWORD='snp#8932' psql -U snp -d snpdb -h 211.208.115.83 -P pager=off -c " - SELECT DISTINCT ON (mmsi) mmsi, time_bucket, - round(ST_Y(ST_EndPoint(track_geom))::numeric, 4) lat, - round(ST_X(ST_EndPoint(track_geom))::numeric, 4) lon - FROM signal.t_vessel_tracks_5min - WHERE mmsi IN ($DARK_MMSIS) AND time_bucket > now() - interval '24 hours' - ORDER BY mmsi, time_bucket DESC; - " 2>&1 | head -30 -else - echo "(no dark vessels in last 1h)" -fi - -echo "" -echo "=== 12. PREDICTION_EVENTS occurred_at distribution by 10-min buckets ===" -PGPASSWORD=Kcg2026ai psql -U kcg-app -d kcgaidb -h 211.208.115.83 -P pager=off -c " -SELECT date_trunc('hour', occurred_at) + (date_part('minute', occurred_at)::int / 10 * interval '10 minutes') bucket, - category, count(*) -FROM kcg.prediction_events -WHERE created_at > now() - interval '1 hour' -GROUP BY bucket, category -ORDER BY bucket DESC, count DESC LIMIT 30; -" 2>&1 - echo "" echo "=== 13. CYCLE LOG (last 65 min) ===" journalctl -u kcg-ai-prediction --since '65 minutes ago' --no-pager 2>/dev/null | \ - grep -E 'lightweight analysis|event_generator:|stats_aggregator hourly|kpi_writer:|analysis cycle:|ERROR|Traceback' | \ + grep -E 'lightweight|event_generator:|stats_aggregator hourly|kpi_writer:|analysis cycle:|pair_trawl|gear_violation|GEAR_ILLEGAL|ERROR|Traceback' | \ tail -60 echo ""