feat(detection): DAR-03 어구 탐지 워크플로우 + 모선 검토 UI + 24h 리플레이 통합

- prediction: G-01/G-04/G-05/G-06 위반 분류 + 쌍끌이 공조 탐지 추가
- backend: 모선 확정/제외 API + signal-batch 항적 프록시 + ParentResolution 점수 근거 필드 확장
- frontend: 어구 탐지 그리드 다중필터/지도 flyTo, 후보 검토 패널(점수 근거+확정/제외), 24h convex hull 리플레이 + TripsLayer 애니메이션
- gitignore: 루트 .venv/ 추가
This commit is contained in:
htlee 2026-04-15 13:26:15 +09:00
부모 359eebe200
커밋 2ee8a0e7ff
37개의 변경된 파일4624개의 추가작업 그리고 489개의 파일을 삭제

1
.gitignore vendored
파일 보기

@ -5,6 +5,7 @@ backend/target/
backend/build/ backend/build/
# === Python (prediction) === # === Python (prediction) ===
.venv/
prediction/.venv/ prediction/.venv/
prediction/__pycache__/ prediction/__pycache__/
prediction/**/__pycache__/ prediction/**/__pycache__/

파일 보기

@ -12,6 +12,7 @@ import org.springframework.context.annotation.Configuration;
public class AppProperties { public class AppProperties {
private Prediction prediction = new Prediction(); private Prediction prediction = new Prediction();
private SignalBatch signalBatch = new SignalBatch();
private IranBackend iranBackend = new IranBackend(); private IranBackend iranBackend = new IranBackend();
private Cors cors = new Cors(); private Cors cors = new Cors();
private Jwt jwt = new Jwt(); private Jwt jwt = new Jwt();
@ -21,6 +22,11 @@ public class AppProperties {
private String baseUrl; private String baseUrl;
} }
@Getter @Setter
public static class SignalBatch {
private String baseUrl;
}
@Getter @Setter @Getter @Setter
public static class IranBackend { public static class IranBackend {
private String baseUrl; private String baseUrl;

파일 보기

@ -1,67 +1,143 @@
package gc.mda.kcg.domain.analysis; package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.permission.annotation.RequirePermission; import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.client.RestClient;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.client.RestClientException;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map; import java.util.Map;
/** /**
* Prediction (Python FastAPI) 서비스 프록시. * Prediction FastAPI 서비스 프록시.
* 현재는 stub - Phase 5에서 연결. * 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 @RestController
@RequestMapping("/api/prediction") @RequestMapping("/api/prediction")
@RequiredArgsConstructor @RequiredArgsConstructor
public class PredictionProxyController { 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") @GetMapping("/health")
public ResponseEntity<?> health() { public ResponseEntity<?> health() {
Map<String, Object> data = iranClient.getJson("/api/prediction/health"); return proxyGet("/health", Map.of(
if (data == null) { "status", "DISCONNECTED",
return ResponseEntity.ok(Map.of( "message", "Prediction 서비스 미연결"
"status", "DISCONNECTED", ));
"message", "Prediction 서비스 미연결 (Phase 5에서 연결 예정)"
));
}
return ResponseEntity.ok(data);
} }
@GetMapping("/status") @GetMapping("/status")
@RequirePermission(resource = "monitoring", operation = "READ") @RequirePermission(resource = "monitoring", operation = "READ")
public ResponseEntity<?> status() { public ResponseEntity<?> status() {
Map<String, Object> data = iranClient.getJson("/api/prediction/status"); return proxyGet("/api/v1/analysis/status", Map.of("status", "DISCONNECTED"));
if (data == null) {
return ResponseEntity.ok(Map.of("status", "DISCONNECTED"));
}
return ResponseEntity.ok(data);
} }
@PostMapping("/trigger") @PostMapping("/trigger")
@RequirePermission(resource = "ai-operations:mlops", operation = "UPDATE") @RequirePermission(resource = "ai-operations:mlops", operation = "UPDATE")
public ResponseEntity<?> trigger() { 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). * AI 채팅 프록시 (POST) Phase 9에서 연결.
* 향후 prediction 인증 통과 SSE 스트리밍으로 전환.
*/ */
@PostMapping("/chat") @PostMapping("/chat")
@RequirePermission(resource = "ai-operations:ai-assistant", operation = "READ") @RequirePermission(resource = "ai-operations:ai-assistant", operation = "READ")
public ResponseEntity<?> chat(@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) { public ResponseEntity<?> chat(@RequestBody Map<String, Object> body) {
// iran 백엔드에 인증 토큰이 필요하므로 현재 stub 응답
// 향후: iranClient에 Bearer 토큰 전달 + SSE 스트리밍
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"ok", false, "ok", false,
"serviceAvailable", 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<String, Object> fallback) {
try {
Map<String, Object> 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<String, Object> fallback) {
try {
var spec = predictionClient.post().uri(path);
Map<String, Object> 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);
}
}
} }

파일 보기

@ -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<String, Object> getGroups(String groupType) {
List<Map<String, Object>> 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<String, ParentResolution> resolutionByKey = new HashMap<>();
for (ParentResolution r : parentResolutionRepo.findAll()) {
resolutionByKey.put(r.getGroupKey() + "::" + r.getSubClusterId(), r);
}
// correlation_scores 실시간 최고 점수 + 후보 일괄 조회
Map<String, Object[]> corrTopByGroup = new HashMap<>();
try {
List<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> items = new ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> latestRows = jdbc.queryForList(latestSql, groupKey);
if (latestRows.isEmpty()) {
return Map.of("serviceAvailable", false, "groupKey", groupKey,
"message", "그룹을 찾을 수 없습니다.");
}
Map<String, Object> latest = buildGroupItem(latestRows.get(0));
List<Map<String, Object>> historyRows = jdbc.queryForList(historySql, groupKey);
List<Map<String, Object>> history = new ArrayList<>();
for (Map<String, Object> row : historyRows) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> rows = jdbc.queryForList(
sql, groupKey, minScore, minScore);
List<Map<String, Object>> items = new ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> 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<String, Object> 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<Map<String, Object>> rows = jdbc.queryForList(sql, groupKey, targetMmsi);
List<Map<String, Object>> items = new ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> 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<String, Object> resolveParent(String groupKey, String action, String targetMmsi, String comment) {
try {
// 먼저 resolution 존재 확인
List<Map<String, Object>> 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<String, Object> buildGroupItem(Map<String, Object> row) {
Map<String, Object> 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<String, Object> parseGeoJson(String geoJson) {
if (geoJson == null || geoJson.isBlank()) return null;
try {
return objectMapper.readValue(geoJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
log.debug("GeoJSON 파싱 실패: {}", e.getMessage());
return null;
}
}
private List<Object> parseJsonArray(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(json, new TypeReference<List<Object>>() {});
} catch (Exception e) {
log.debug("JSON 배열 파싱 실패: {}", e.getMessage());
return List.of();
}
}
}

파일 보기

@ -1,109 +1,85 @@
package gc.mda.kcg.domain.analysis; package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.domain.fleet.ParentResolution; import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository;
import gc.mda.kcg.permission.annotation.RequirePermission; import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; 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 어구/선단 그룹 + parentResolution 합성 * GET /api/vessel-analysis/groups/{key}/detail 단일 그룹 상세 + 24h 이력
* GET /api/vessel-analysis/groups/{key}/detail 단일 그룹 상세
* GET /api/vessel-analysis/groups/{key}/correlations 상관관계 점수 * GET /api/vessel-analysis/groups/{key}/correlations 상관관계 점수
* *
* 권한: detection / detection:gear-detection (READ) * 권한: detection:gear-detection (READ)
*/ */
@Slf4j
@RestController @RestController
@RequestMapping("/api/vessel-analysis") @RequestMapping("/api/vessel-analysis")
@RequiredArgsConstructor @RequiredArgsConstructor
public class VesselAnalysisProxyController { public class VesselAnalysisProxyController {
private final IranBackendClient iranClient; private final VesselAnalysisGroupService groupService;
private final ParentResolutionRepository resolutionRepository; 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 @GetMapping
@RequirePermission(resource = "detection:dark-vessel", operation = "READ") @RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public ResponseEntity<?> getVesselAnalysis() { public ResponseEntity<?> getVesselAnalysis() {
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis"); // vessel_analysis_results 직접 조회는 /api/analysis/vessels 사용.
if (data == null) { // 엔드포인트는 하위 호환을 위해 구조 반환.
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"serviceAvailable", false, "serviceAvailable", true,
"message", "iran 백엔드 미연결", "message", "vessel_analysis_results는 /api/analysis/vessels 에서 제공됩니다.",
"items", List.of(), "items", List.of(),
"stats", Map.of(), "stats", Map.of(),
"count", 0 "count", 0
)); ));
}
// 통과 + 메타데이터 추가
Map<String, Object> enriched = new LinkedHashMap<>(data);
enriched.put("serviceAvailable", true);
return ResponseEntity.ok(enriched);
} }
/** /**
* 그룹 목록 + 자체 DB의 parentResolution 합성. * 그룹 목록 + 자체 DB의 parentResolution 합성.
* 그룹에 resolution 필드 추가.
*/ */
@GetMapping("/groups") @GetMapping("/groups")
@RequirePermission(resource = "detection:gear-detection", operation = "READ") @RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroups() { public ResponseEntity<?> getGroups(
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups"); @org.springframework.web.bind.annotation.RequestParam(required = false) String groupType
if (data == null) { ) {
return ResponseEntity.ok(Map.of( Map<String, Object> result = groupService.getGroups(groupType);
"serviceAvailable", false,
"items", List.of(),
"count", 0
));
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) data.getOrDefault("items", List.of());
// 자체 DB의 모든 resolution을 group_key로 인덱싱
Map<String, ParentResolution> resolutionByKey = new HashMap<>();
for (ParentResolution r : resolutionRepository.findAll()) {
resolutionByKey.put(r.getGroupKey() + "::" + r.getSubClusterId(), r);
}
// 그룹에 합성
for (Map<String, Object> 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<String, Object> 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<String, Object> result = new LinkedHashMap<>(data);
result.put("items", items);
result.put("serviceAvailable", true);
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
@GetMapping("/groups/{groupKey}/detail") @GetMapping("/groups/{groupKey}/detail")
@RequirePermission(resource = "detection:gear-detection", operation = "READ") @RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroupDetail(@PathVariable String groupKey) { public ResponseEntity<?> getGroupDetail(@PathVariable String groupKey) {
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups/" + groupKey + "/detail"); Map<String, Object> result = groupService.getGroupDetail(groupKey);
if (data == null) { return ResponseEntity.ok(result);
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey));
}
return ResponseEntity.ok(data);
} }
@GetMapping("/groups/{groupKey}/correlations") @GetMapping("/groups/{groupKey}/correlations")
@ -112,12 +88,57 @@ public class VesselAnalysisProxyController {
@PathVariable String groupKey, @PathVariable String groupKey,
@RequestParam(required = false) Double minScore @RequestParam(required = false) Double minScore
) { ) {
String path = "/api/vessel-analysis/groups/" + groupKey + "/correlations"; Map<String, Object> result = groupService.getGroupCorrelations(groupKey, minScore);
if (minScore != null) path += "?minScore=" + minScore; return ResponseEntity.ok(result);
Map<String, Object> data = iranClient.getJson(path); }
if (data == null) {
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey)); /**
* 후보 상세 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<String, String> 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<String, Object> 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);
} }
} }

파일 보기

@ -34,6 +34,27 @@ public class ParentResolution {
@Column(name = "selected_parent_mmsi", length = 20) @Column(name = "selected_parent_mmsi", length = 20)
private String selectedParentMmsi; 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) @Column(name = "rejected_candidate_mmsi", length = 20)
private String rejectedCandidateMmsi; private String rejectedCandidateMmsi;

파일 보기

@ -40,6 +40,10 @@ spring:
server: server:
port: 8080 port: 8080
forward-headers-strategy: framework forward-headers-strategy: framework
compression:
enabled: true
min-response-size: 1024
mime-types: application/json,application/xml,text/html,text/plain
management: management:
endpoints: endpoints:
@ -60,6 +64,8 @@ logging:
app: app:
prediction: prediction:
base-url: ${PREDICTION_BASE_URL:http://localhost:8001} 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: iran-backend:
# 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합) # 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
base-url: ${IRAN_BACKEND_BASE_URL:https://kcg.gc-si.dev} base-url: ${IRAN_BACKEND_BASE_URL:https://kcg.gc-si.dev}

파일 보기

@ -4,20 +4,61 @@ import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Anchor, AlertTriangle, Loader2 } from 'lucide-react'; import { Anchor, AlertTriangle, Loader2, Filter, X } from 'lucide-react';
import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { MapboxOverlay } from '@deck.gl/mapbox';
import type { MarkerData } from '@lib/map'; import {
BaseMap, createStaticLayers,
createGeoJsonLayer, createGearPolygonLayer,
createShipIconLayer, createGearIconLayer,
type MapHandle,
type ShipIconData, type GearIconData,
} from '@lib/map';
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi'; import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
import { formatDate } from '@shared/utils/dateFormat'; import { formatDate } from '@shared/utils/dateFormat';
import { getPermitStatusIntent, getPermitStatusLabel, getGearJudgmentIntent } from '@shared/constants/permissionStatuses'; import { getPermitStatusIntent, getPermitStatusLabel, getGearJudgmentIntent } from '@shared/constants/permissionStatuses';
import { getAlertLevelHex } from '@shared/constants/alertLevels'; import { getAlertLevelHex } from '@shared/constants/alertLevels';
import { useSettingsStore } from '@stores/settingsStore'; 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: 불법 어망·어구 탐지 및 관리 */ /* 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<string, Record<string, unknown>>;
pairTrawlDetected: boolean;
pairTrawlPairMmsi: string;
allowedGears: string[];
topScore: number; // 최대 후보 일치율 (0~1)
};
// 한글 위험도 → AlertLevel hex 매핑
const RISK_HEX: Record<string, string> = { const RISK_HEX: Record<string, string> = {
'고위험': getAlertLevelHex('CRITICAL'), '고위험': getAlertLevelHex('CRITICAL'),
'중위험': getAlertLevelHex('MEDIUM'), '중위험': getAlertLevelHex('MEDIUM'),
@ -37,14 +78,31 @@ function deriveStatus(g: GearGroupItem): string {
return '확인 중'; 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 risk = deriveRisk(g);
const status = deriveStatus(g); const status = deriveStatus(g);
const zone = deriveZone(g);
// 그룹명: 항상 groupLabel/groupKey 사용 (모선 후보 MMSI는 별도 칼럼)
const owner = g.groupLabel || g.groupKey;
return { return {
id: `G-${String(idx + 1).padStart(3, '0')}`, id: `G-${String(idx + 1).padStart(3, '0')}`,
type: g.groupLabel || (g.groupType === 'GEAR_IN_ZONE' ? '지정해역 어구' : '지정해역 외 어구'), groupKey: g.groupKey,
owner: g.members[0]?.name || g.members[0]?.mmsi || '-', type: getGearGroupTypeLabel(g.groupType, t, lang),
zone: g.groupType === 'GEAR_IN_ZONE' ? '지정해역' : '지정해역 외', owner,
zone,
status, status,
permit: 'NONE', permit: 'NONE',
installed: formatDate(g.snapshotTime), installed: formatDate(g.snapshotTime),
@ -54,10 +112,54 @@ function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
lng: g.centerLon, lng: g.centerLon,
parentStatus: g.resolution?.status ?? '-', parentStatus: g.resolution?.status ?? '-',
parentMmsi: g.resolution?.selectedParentMmsi ?? '-', 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<string>;
onChange: (v: Set<string>) => 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 (
<div className="space-y-1">
<div className="text-[10px] text-hint font-medium">{label} {selected.size > 0 && <span className="text-primary">({selected.size})</span>}</div>
<div className="flex flex-wrap gap-x-3 gap-y-1">
{options.map(o => (
<label key={o.value} className="flex items-center gap-1.5 text-[10px] text-label cursor-pointer hover:text-heading">
<input type="checkbox" checked={selected.has(o.value)} onChange={() => toggle(o.value)}
className="w-3 h-3 rounded border-border accent-primary" />
{o.label}
</label>
))}
</div>
</div>
);
}
function ReplayOverlay() {
const groupKey = useGearReplayStore(s => s.groupKey);
if (!groupKey) return null;
return <GearReplayController onClose={() => useGearReplayStore.getState().reset()} />;
}
export function GearDetection() { export function GearDetection() {
const { t } = useTranslation('detection'); const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common'); const { t: tc } = useTranslation('common');
@ -65,43 +167,104 @@ export function GearDetection() {
const cols: DataColumn<Gear>[] = useMemo(() => [ const cols: DataColumn<Gear>[] = useMemo(() => [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> }, { key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> }, { key: 'type', label: '그룹 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium text-[11px]">{v as string}</span> },
{ key: 'owner', label: '소유 선박', sortable: true, render: v => <span className="text-cyan-400">{v as string}</span> }, { key: 'owner', label: '어구 그룹', sortable: true,
{ key: 'zone', label: '설치 해역', width: '90px', sortable: true }, render: v => <span className="text-cyan-400 font-mono text-[11px]">{v as string}</span> },
{ key: 'permit', label: '허가 상태', width: '80px', align: 'center', { key: 'memberCount', label: '멤버', width: '50px', align: 'center',
render: v => <span className="font-mono text-[10px] text-label">{v as number}</span> },
{ key: 'zone', label: '설치 해역', width: '130px', sortable: true,
render: (v: unknown) => (
<Badge intent={getZoneCodeIntent(v as string)} size="sm">
{getZoneCodeLabel(v as string, t, lang)}
</Badge>
) },
{ key: 'permit', label: '허가', width: '70px', align: 'center',
render: v => <Badge intent={getPermitStatusIntent(v as string)} size="sm">{getPermitStatusLabel(v as string, tc, lang)}</Badge> }, render: v => <Badge intent={getPermitStatusIntent(v as string)} size="sm">{getPermitStatusLabel(v as string, tc, lang)}</Badge> },
{ key: 'status', label: '판정', width: '80px', align: 'center', sortable: true, { key: 'status', label: '판정', width: '80px', align: 'center', sortable: true,
render: v => <Badge intent={getGearJudgmentIntent(v as string)} size="sm">{v as string}</Badge> }, render: v => <Badge intent={getGearJudgmentIntent(v as string)} size="sm">{v as string}</Badge> },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, { key: 'gCodes', label: 'G코드', width: '100px',
render: (_: unknown, row: Gear) => row.gCodes.length > 0 ? (
<div className="flex flex-wrap gap-0.5">
{row.gCodes.map(code => (
<Badge key={code} intent={getGearViolationIntent(code)} size="sm">{code}</Badge>
))}
</div>
) : <span className="text-hint text-[10px]">-</span> },
{ 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 <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } }, render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } },
{ key: 'parentStatus', label: '모선 상태', width: '100px', sortable: true, { key: 'parentStatus', label: '모선 상태', width: '90px', sortable: true,
render: v => { render: v => {
const s = v as string; 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; const label = s === 'DIRECT_PARENT_MATCH' ? '직접매칭' : s === 'AUTO_PROMOTED' ? '자동승격' : s === 'REVIEW_REQUIRED' ? '심사필요' : s === 'UNRESOLVED' ? '미결정' : s;
return <Badge intent={intent} size="sm">{label}</Badge>; return <Badge intent={intent} size="sm">{label}</Badge>;
} }, } },
{ key: 'parentMmsi', label: '추정 모선', width: '100px', { key: 'parentMmsi', label: '추정 모선', width: '100px',
render: v => { const m = v as string; return m !== '-' ? <span className="text-cyan-400 font-mono text-[10px]">{m}</span> : <span className="text-hint">-</span>; } }, render: v => { const m = v as string; return m !== '-' ? <span className="text-cyan-400 font-mono text-[10px]">{m}</span> : <span className="text-hint">-</span>; } },
{ key: 'confidence', label: '후보', width: '50px', align: 'center', { key: 'topScore', label: '후보 일치', width: '75px', align: 'center', sortable: true,
render: v => <span className="font-mono text-[10px] text-label">{v as string}</span> }, render: (v: unknown) => {
const s = v as number;
if (s <= 0) return <span className="text-hint text-[10px]">-</span>;
const pct = Math.round(s * 100);
const c = pct >= 72 ? 'text-green-400' : pct >= 50 ? 'text-yellow-400' : 'text-hint';
return <span className={`font-mono text-[10px] font-bold ${c}`}>{pct}%</span>;
} },
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> }, { key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
], [tc, lang]); ], [t, tc, lang]);
const [groups, setGroups] = useState<GearGroupItem[]>([]); const [groups, setGroups] = useState<GearGroupItem[]>([]);
const [serviceAvailable, setServiceAvailable] = useState(true); const [serviceAvailable, setServiceAvailable] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
// ── 필터 상태 (다중 선택, localStorage 영속화) ──
const [filterOpen, setFilterOpen] = useState(() => {
try { return JSON.parse(localStorage.getItem('kcg-gear-filter-open') ?? 'false'); } catch { return false; }
});
const [filterZone, setFilterZone] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fz') ?? '[]')); } catch { return new Set(); }
});
const [filterStatus, setFilterStatus] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fs') ?? '[]')); } catch { return new Set(); }
});
const [filterRisk, setFilterRisk] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fr') ?? '[]')); } catch { return new Set(); }
});
const [filterParentStatus, setFilterParentStatus] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fps') ?? '[]')); } catch { return new Set(); }
});
const [filterPermit, setFilterPermit] = useState<Set<string>>(() => {
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 () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const res = await fetchGroups(); const res = await fetchGroups('GEAR');
setServiceAvailable(res.serviceAvailable); setServiceAvailable(res.serviceAvailable);
setGroups(res.items.filter( setGroups(res.items);
(i) => i.groupType === 'GEAR_IN_ZONE' || i.groupType === 'GEAR_OUT_ZONE',
));
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다'); setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
setServiceAvailable(false); setServiceAvailable(false);
@ -113,39 +276,175 @@ export function GearDetection() {
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
const DATA: Gear[] = useMemo( const DATA: Gear[] = useMemo(
() => groups.map((g, i) => mapGroupToGear(g, i)), () => groups.map((g, i) => mapGroupToGear(g, i, t, lang)),
[groups], [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<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [ // overlay Proxy ref — mapRef.current.overlay를 항상 최신으로 참조
...createStaticLayers(), // iran 패턴: 리플레이 훅이 overlay.setProps() 직접 호출
createRadiusLayer( const overlayRef = useMemo<React.RefObject<MapboxOverlay | null>>(() => ({
'gear-radius', get current() { return mapRef.current?.overlay ?? null; },
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]);
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<string, unknown>).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<string, number> = {};
filteredData.forEach(d => { stats[d.zone] = (stats[d.zone] || 0) + 1; });
return stats;
}, [filteredData]);
return ( return (
<>
<GearDetailPanel gear={selectedGear} onClose={() => setSelectedId(null)} />
<PageContainer> <PageContainer>
<PageHeader <PageHeader
icon={Anchor} icon={Anchor}
@ -161,9 +460,7 @@ export function GearDetection() {
</div> </div>
)} )}
{error && ( {error && <div className="text-xs text-red-400">: {error}</div>}
<div className="text-xs text-red-400">: {error}</div>
)}
{loading && ( {loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground"> <div className="flex items-center justify-center py-8 text-muted-foreground">
@ -171,33 +468,152 @@ export function GearDetection() {
</div> </div>
)} )}
<div className="flex gap-2"> {/* 요약 배지 */}
<div className="flex gap-2 flex-wrap">
{[ {[
{ l: '전체 어구 그룹', v: DATA.length, c: 'text-heading' }, { l: '전체 어구 그룹', v: filteredData.length, c: 'text-heading' },
{ l: '불법 의심', v: DATA.filter(d => d.status.includes('불법')).length, c: 'text-red-400' }, { l: '불법 의심', v: filteredData.filter(d => d.status.includes('불법')).length, c: 'text-red-400' },
{ l: '확인 중', v: DATA.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' }, { l: '확인 중', v: filteredData.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' },
{ l: '정상', v: DATA.filter(d => d.status === '정상').length, c: 'text-green-400' }, { l: '정상', v: filteredData.filter(d => d.status === '정상').length, c: 'text-green-400' },
].map(k => ( ].map(k => (
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card"> <div key={k.l} className="flex-1 min-w-[100px] flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span> <span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
</div> </div>
))} ))}
</div> </div>
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="어구유형, 소유선박, 해역 검색..." searchKeys={['type', 'owner', 'zone']} exportFilename="어구탐지" /> {/* 수역별 분포 */}
<div className="flex gap-1.5 flex-wrap">
{Object.entries(zoneStats).sort(([,a],[,b]) => b - a).map(([zone, cnt]) => (
<Badge key={zone} intent={getZoneCodeIntent(zone)} size="sm">
{getZoneCodeLabel(zone, t, lang)} {cnt}
</Badge>
))}
</div>
{/* 필터 토글 버튼 */}
<div className="flex items-center gap-2">
<button type="button" onClick={() => setFilterOpen(!filterOpen)} aria-label="필터 설정"
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border rounded-lg transition-colors ${
hasActiveFilter
? 'bg-primary/10 border-primary/40 text-heading'
: 'bg-surface-raised border-border text-label hover:border-primary/50'
}`}>
<Filter className="w-3.5 h-3.5" />
{hasActiveFilter && (
<span className="bg-primary text-on-vivid rounded-full px-1.5 py-0.5 text-[9px] font-bold leading-none">
{filterZone.size + filterStatus.size + filterRisk.size + filterParentStatus.size + filterPermit.size}
</span>
)}
</button>
{hasActiveFilter && (
<>
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length}</span>
<button type="button" aria-label="필터 초기화"
onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }}
className="flex items-center gap-1 px-2 py-1 text-[10px] text-hint hover:text-label rounded hover:bg-surface-raised">
<X className="w-3 h-3" />
</button>
</>
)}
</div>
{/* 필터 패널 (접기/펼치기) */}
{filterOpen && (
<div className="bg-surface-raised border border-border rounded-lg p-3 space-y-3">
<FilterCheckGroup label="설치 해역" selected={filterZone} onChange={setFilterZone}
options={filterOptions.zones.map(z => ({ value: z, label: getZoneCodeLabel(z, t, lang) }))} />
<FilterCheckGroup label="판정" selected={filterStatus} onChange={setFilterStatus}
options={filterOptions.statuses.map(s => ({ value: s, label: s }))} />
<FilterCheckGroup label="위험도" selected={filterRisk} onChange={setFilterRisk}
options={filterOptions.risks.map(r => ({ value: r, label: r }))} />
<FilterCheckGroup label="모선 상태" selected={filterParentStatus} onChange={setFilterParentStatus}
options={filterOptions.parentStatuses.map(s => ({
value: s,
label: s === 'DIRECT_PARENT_MATCH' ? '직접매칭' : s === 'AUTO_PROMOTED' ? '자동승격'
: s === 'REVIEW_REQUIRED' ? '심사필요' : s === 'UNRESOLVED' ? '미결정' : s,
}))} />
<FilterCheckGroup label="허가" selected={filterPermit} onChange={setFilterPermit}
options={filterOptions.permits.map(p => ({ value: p, label: getPermitStatusLabel(p, tc, lang) }))} />
{/* 멤버 수 범위 슬라이더 */}
{filterOptions.maxMember > 2 && (
<div className="space-y-1">
<div className="text-[10px] text-hint font-medium">
<span className="text-label font-bold">{filterMemberMin}~{filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[9px] text-hint w-6 text-right">{filterMemberMin}</span>
<input type="range" min={2} max={filterOptions.maxMember}
value={filterMemberMin} onChange={e => setFilterMemberMin(Number(e.target.value))}
aria-label="최소 멤버 수"
className="flex-1 h-1 accent-primary cursor-pointer" />
<input type="range" min={2} max={filterOptions.maxMember}
value={filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}
onChange={e => setFilterMemberMax(Number(e.target.value))}
aria-label="최대 멤버 수"
className="flex-1 h-1 accent-primary cursor-pointer" />
<span className="text-[9px] text-hint w-6">{filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}</span>
</div>
</div>
)}
{/* 패널 내 초기화 */}
<div className="pt-2 border-t border-border flex items-center justify-between">
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length} </span>
<button type="button" aria-label="필터 초기화"
onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }}
className="flex items-center gap-1 px-2 py-1 text-[10px] text-hint hover:text-label rounded hover:bg-surface-overlay">
<X className="w-3 h-3" />
</button>
</div>
</div>
)}
<DataTable
data={filteredData}
columns={cols}
pageSize={10}
searchPlaceholder="그룹유형, 모선, 해역 검색..."
searchKeys={['type', 'owner', 'zone', 'groupKey']}
exportFilename="어구탐지"
onRowClick={(row: Gear) => {
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,
});
}
}
}}
/>
{/* 어구 탐지 위치 지도 */} {/* 어구 탐지 위치 지도 */}
<Card> <Card>
<CardContent className="p-0 relative"> <CardContent className="p-0 relative">
<BaseMap ref={mapRef} center={[36.5, 127.0]} zoom={7} height={450} className="rounded-lg overflow-hidden" /> <BaseMap ref={mapRef} center={[34.5, 126.0]} zoom={7} height={500} className="rounded-lg overflow-hidden" />
{/* 범례 */} {/* 범례 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2"> <div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1.5"> </div> <div className="text-[9px] text-muted-foreground font-bold mb-1.5"></div>
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-red-500" /><span className="text-[8px] text-muted-foreground"> ( /)</span></div> <div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-red-500" /><span className="text-[8px] text-muted-foreground"> </span></div>
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-yellow-500" /><span className="text-[8px] text-muted-foreground"> ( )</span></div> <div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-yellow-500" /><span className="text-[8px] text-muted-foreground"> ( )</span></div>
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-green-500" /><span className="text-[8px] text-muted-foreground"> ()</span></div> <div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-green-500" /><span className="text-[8px] text-muted-foreground"> ()</span></div>
</div> </div>
<div className="mt-1.5 pt-1.5 border-t border-border space-y-1">
<div className="text-[8px] text-muted-foreground font-bold"></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-purple-500/30 border border-purple-500/60" /><span className="text-[8px] text-muted-foreground"> I ()</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-blue-500/30 border border-blue-500/60" /><span className="text-[8px] text-muted-foreground"> II ()</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-cyan-500/30 border border-cyan-500/60" /><span className="text-[8px] text-muted-foreground"> III ()</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-amber-500/30 border border-amber-500/60" /><span className="text-[8px] text-muted-foreground"> IV ()</span></div>
</div>
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border"> <div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div> <div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div>
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div> <div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div>
@ -206,10 +622,13 @@ export function GearDetection() {
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5"> <div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<Anchor className="w-3.5 h-3.5 text-orange-400" /> <Anchor className="w-3.5 h-3.5 text-orange-400" />
<span className="text-[10px] text-orange-400 font-bold">{DATA.length}</span> <span className="text-[10px] text-orange-400 font-bold">{DATA.length}</span>
<span className="text-[9px] text-hint"> </span> <span className="text-[9px] text-hint"> </span>
</div> </div>
{/* 리플레이 컨트롤러 (활성 시 표시) */}
<ReplayOverlay />
</CardContent> </CardContent>
</Card> </Card>
</PageContainer> </PageContainer>
</>
); );
} }

파일 보기

@ -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<string, Record<string, unknown>>;
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<CorrelationItem[]>([]);
const [corrLoading, setCorrLoading] = useState(false);
const [selectedCandidates, setSelectedCandidates] = useState<Set<string>>(new Set());
const [selectedDetail, setSelectedDetail] = useState<string | null>(null); // 상세 보기 중인 후보 MMSI
const [detailMetrics, setDetailMetrics] = useState<CandidateMetricItem[]>([]);
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<string, unknown>)?.items as unknown[]) ?? [];
// 같은 MMSI가 여러 모델에서 중복 → default 모델 우선, 없으면 첫 번째
const byMmsi = new Map<string, CorrelationItem>();
for (const item of items) {
const d = item as Record<string, unknown>;
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<string, unknown>) => ({
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<string, unknown>) => ({
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 (
<div className="fixed inset-y-0 right-0 w-[420px] bg-background border-l border-border z-50 overflow-y-auto shadow-2xl">
{/* 헤더 */}
<div className="sticky top-0 bg-background/95 backdrop-blur-sm border-b border-border px-4 py-3 flex items-center justify-between z-10">
<div className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-orange-400" />
<span className="font-bold text-heading text-sm"> </span>
<span className="text-xs font-mono font-bold text-hint">{gear.id}</span>
<Badge intent={getZoneCodeIntent(gear.zone)} size="sm">
{getZoneCodeLabel(gear.zone, t, lang)}
</Badge>
</div>
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label="닫기">
<X className="w-4 h-4 text-hint" />
</button>
</div>
<div className="p-4 space-y-4">
{/* G코드 위반 내역 */}
{gear.gCodes.length > 0 && (
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<ShieldAlert className="w-3.5 h-3.5 text-red-400" />
<span className="text-label font-medium">G코드 </span>
<span className="text-hint text-[10px]"> {gear.gearViolationScore}</span>
</div>
<div className="space-y-1.5">
{gear.gCodes.map((code) => {
const meta = GEAR_VIOLATION_CODES[code as keyof typeof GEAR_VIOLATION_CODES];
return (
<div key={code} className="flex items-start gap-2 py-1.5 border-b border-border last:border-0">
<Badge intent={getGearViolationIntent(code)} size="sm" className="shrink-0 mt-0.5">{code}</Badge>
<div className="flex-1 min-w-0">
<div className="text-xs text-label font-medium">{getGearViolationLabel(code, t, lang)}</div>
<div className="text-[10px] text-hint mt-0.5">{getGearViolationDesc(code, lang)}</div>
</div>
{meta && <span className="text-[10px] font-mono text-label shrink-0">+{meta.score}pt</span>}
</div>
);
})}
</div>
</div>
)}
{/* 어구 그룹 정보 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Anchor className="w-3.5 h-3.5 text-orange-400" />
<span className="text-label font-medium"> </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint"> </span>
<span className="text-label text-right font-mono text-[10px]">{gear.groupKey}</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{gear.type}</span>
<span className="text-hint">/</span>
<span className="text-label text-right font-mono">{gear.owner}</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{gear.memberCount > 0 ? `${gear.memberCount}` : '-'}</span>
<span className="text-hint"> </span>
<span className="text-right">
<Badge intent={getZoneCodeIntent(gear.zone)} size="sm">{getZoneCodeLabel(gear.zone, t, lang)}</Badge>
</span>
<span className="text-hint"> </span>
<span className="text-right">
<Badge intent={getGearJudgmentIntent(gear.status)} size="sm">{gear.status}</Badge>
</span>
<span className="text-hint"> </span>
<span className="text-label text-right text-[10px]">{allowedGears.length > 0 ? allowedGears.join(', ') : '없음'}</span>
<span className="text-hint"></span>
<span className="text-label text-right font-mono text-[10px]">
{gear.lat.toFixed(4)}°N {gear.lng.toFixed(4)}°E
</span>
</div>
</div>
{/* 모선 추론 정보 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Ship className="w-3.5 h-3.5 text-cyan-400" />
<span className="text-label font-medium"> </span>
<Badge intent={parentStatusIntent} size="sm">{parentStatusLabel}</Badge>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">
{gear.parentMmsi !== '-' && gear.parentMmsi ? (
<button type="button" className="text-cyan-400 hover:underline"
onClick={() => navigate(`/vessel/${gear.parentMmsi}`)}>
{gear.parentMmsi}
</button>
) : '-'}
</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{gear.confidence}</span>
</div>
</div>
{/* 모선 추론 후보 상세 (Correlation) */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<TrendingUp className="w-3.5 h-3.5 text-purple-400" />
<span className="text-label font-medium"> </span>
{corrLoading && <Loader2 className="w-3 h-3 animate-spin text-hint" />}
<span className="text-hint text-[10px]">{correlations.length}</span>
</div>
{correlations.length > 0 ? (
<div className="space-y-1.5 max-h-[240px] overflow-y-auto">
{correlations.sort((a, b) => b.score - a.score).map((c, i) => (
<div key={`${i}-${c.targetMmsi}`}
onClick={(e) => {
// 체크박스, 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'
}`}>
<div className="flex items-center gap-2">
<input type="checkbox" checked={selectedCandidates.has(c.targetMmsi)}
onChange={() => toggleCandidate(c.targetMmsi)}
className="w-3 h-3 rounded border-border accent-emerald-500 shrink-0 cursor-pointer"
aria-label={`${c.targetMmsi} 리플레이 선택`} />
<button type="button"
className="text-cyan-400 hover:underline font-mono text-[11px]"
onClick={() => navigate(`/vessel/${c.targetMmsi}`)}>
{c.targetMmsi}
</button>
<span className="text-hint text-[9px] truncate max-w-[80px]">{c.targetName}</span>
<div className="flex-1" />
<div className="w-14 h-1.5 bg-surface-overlay rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all"
style={{
width: `${Math.min(100, c.score * 100)}%`,
backgroundColor: c.score >= 0.72 ? '#10b981' : c.score >= 0.5 ? '#f59e0b' : '#64748b',
}} />
</div>
<span className="font-mono text-[10px] text-label w-10 text-right">
{(c.score * 100).toFixed(0)}%
</span>
<Badge intent={c.freezeState === 'ACTIVE' ? 'success' : 'muted'} size="sm">
{c.freezeState === 'ACTIVE' ? '활성' : c.freezeState.slice(0, 4)}
</Badge>
</div>
<div className="flex items-center gap-2 mt-1 ml-6 text-[9px] text-hint">
<span>: {(c.proximityRatio * 100).toFixed(0)}%</span>
{c.visitScore > 0 && <span>: {(c.visitScore * 100).toFixed(0)}%</span>}
{c.headingCoherence > 0 && <span>: {(c.headingCoherence * 100).toFixed(0)}%</span>}
<span>: {c.streak}</span>
<Badge intent={c.targetType === 'VESSEL' ? 'info' : 'muted'} size="sm">
{c.targetType === 'VESSEL' ? '선박' : c.targetType === 'GEAR_BUOY' ? '어구' : c.targetType}
</Badge>
</div>
</div>
))}
</div>
) : !corrLoading ? (
<div className="text-hint text-[10px] text-center py-2"> </div>
) : null}
{correlations.length > 0 && (
<div className="flex items-center gap-3 pt-1.5 border-t border-border text-[9px] text-hint">
<span>근접: 어구- </span>
<span>방문: 어구 </span>
<span>방향: 침로 </span>
</div>
)}
</div>
{/* ── 후보 상세 검토 패널 ── */}
{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) => (
<div className="w-full h-1.5 bg-surface-overlay rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${Math.min(100, (v ?? 0) * 100)}%`, backgroundColor: color }} />
</div>
);
return (
<div className="bg-surface-raised rounded-lg p-3 space-y-3 border border-purple-500/30">
{/* 헤더 */}
<div className="flex items-center gap-2 text-xs">
<BarChart3 className="w-3.5 h-3.5 text-purple-400" />
<span className="text-label font-medium"> </span>
<span className="text-cyan-400 font-mono text-[11px]">{cand.targetMmsi}</span>
<span className="text-hint text-[9px] truncate">{cand.targetName}</span>
</div>
{/* 종합 점수 */}
<div className="flex items-center gap-3 py-2 border-y border-border">
<div className="text-center flex-1">
<div className={`text-lg font-bold ${cand.score >= 0.72 ? 'text-green-400' : cand.score >= 0.5 ? 'text-yellow-400' : 'text-hint'}`}>
{(cand.score * 100).toFixed(1)}%
</div>
<div className="text-[9px] text-hint"> </div>
</div>
<div className="text-center flex-1">
<div className="text-sm font-bold text-label">{cand.streak}</div>
<div className="text-[9px] text-hint"> </div>
</div>
<div className="text-center flex-1">
<div className="text-sm font-bold text-label">{detailMetrics.length}</div>
<div className="text-[9px] text-hint">raw </div>
</div>
</div>
{/* 점수 근거 상세 */}
{detailLoading ? (
<div className="flex items-center justify-center py-4"><Loader2 className="w-4 h-4 animate-spin text-hint" /></div>
) : (
<div className="space-y-2 text-[10px]">
<div className="text-hint font-medium text-[9px]"> ( {detailMetrics.length} )</div>
{[
{ 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 }) => (
<div key={label} className="flex items-center gap-2">
<span className="w-[70px] text-label shrink-0">{label}</span>
<div className="flex-1">{bar(value, color)}</div>
<span className="w-10 text-right font-mono text-label">{pct(value)}</span>
<span className="w-[90px] text-hint text-[8px] truncate" title={desc}>{desc}</span>
</div>
))}
{/* 보정 지표 */}
<div className="text-hint font-medium text-[9px] pt-1 border-t border-border"> </div>
<div className="grid grid-cols-2 gap-y-1 gap-x-4">
<span className="text-hint"> </span>
<span className="text-label text-right">{shadowStayCount}/{detailMetrics.length}</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{shadowReturnCount}/{detailMetrics.length}</span>
<span className="text-hint"> </span>
<span className="text-right"><Badge intent={cand.freezeState === 'ACTIVE' ? 'success' : 'muted'} size="sm">{cand.freezeState === 'ACTIVE' ? '활성' : cand.freezeState}</Badge></span>
<span className="text-hint"> </span>
<span className="text-right"><Badge intent={cand.targetType === 'VESSEL' ? 'info' : 'muted'} size="sm">{cand.targetType === 'VESSEL' ? '선박' : cand.targetType === 'GEAR_BUOY' ? '어구' : cand.targetType}</Badge></span>
</div>
</div>
)}
{/* 확정/제외 버튼 */}
{resolveMsg && (
<div className={`text-[10px] text-center py-1 rounded ${resolveMsg.includes('완료') ? 'text-green-400 bg-green-500/10' : 'text-red-400 bg-red-500/10'}`}>
{resolveMsg}
</div>
)}
<div className="flex gap-2">
<Button variant="primary" size="sm" className="flex-1"
onClick={() => handleResolve('confirm')} disabled={resolveLoading}>
<CheckCircle className="w-3.5 h-3.5 mr-1" />
{resolveLoading ? '처리 중...' : '모선 확정'}
</Button>
<Button variant="outline" size="sm" className="flex-1"
onClick={() => handleResolve('reject')} disabled={resolveLoading}>
<XCircle className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
);
})()}
{/* 궤적 리플레이 버튼 */}
<Button
variant={isReplayActive ? 'secondary' : 'primary'}
size="sm"
className="w-full"
onClick={handleStartReplay}
disabled={replayLoading}
>
{replayLoading ? (
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />
) : (
<Play className="w-3.5 h-3.5 mr-1" />
)}
{replayLoading ? '데이터 로딩...'
: isReplayActive ? '리플레이 재시작'
: selectedCandidates.size > 0
? `리플레이 (후보 ${selectedCandidates.size}척 포함)`
: '24시간 궤적 리플레이'}
</Button>
{/* 쌍끌이 감지 정보 */}
{hasPairTrawl && (
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Users className="w-3.5 h-3.5 text-red-400" />
<span className="text-label font-medium"> </span>
<Badge intent="critical" size="sm">G-06</Badge>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">
{gear.pairTrawlPairMmsi ? (
<button type="button" className="text-cyan-400 hover:underline"
onClick={() => navigate(`/vessel/${gear.pairTrawlPairMmsi}`)}>
{gear.pairTrawlPairMmsi}
</button>
) : '-'}
</span>
{gear.gearViolationEvidence['G-06'] && (() => {
const ev = gear.gearViolationEvidence['G-06'];
return (
<>
{ev.sync_duration_min != null && (
<>
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">{String(ev.sync_duration_min)}</span>
</>
)}
{ev.mean_separation_nm != null && (
<>
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">{(Number(ev.mean_separation_nm) * 1852).toFixed(0)}m</span>
</>
)}
</>
);
})()}
</div>
</div>
)}
{/* 위치 + 액션 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<MapPin className="w-3.5 h-3.5 text-yellow-400" />
<span className="text-label font-medium"></span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint"></span>
<span className="text-label text-right font-mono">{gear.lat.toFixed(6)}°N</span>
<span className="text-hint"></span>
<span className="text-label text-right font-mono">{gear.lng.toFixed(6)}°E</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1"
onClick={() => navigate(`/vessel/${gear.owner}`)}>
<Ship className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="primary" size="sm" className="flex-1"
onClick={() => { /* TODO: 단속 대상 등록 API */ }}>
<ShieldAlert className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
</div>
);
}

파일 보기

@ -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<HTMLDivElement>(null);
const timeLabelRef = useRef<HTMLSpanElement>(null);
const trackRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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 (
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 z-[1001] w-full max-w-[520px] px-3">
<div className="bg-background/95 backdrop-blur-sm border border-border rounded-lg px-3 py-2 shadow-lg flex items-center gap-2">
{/* Play / Pause */}
<Button
type="button"
variant="ghost"
size="sm"
aria-label={isPlaying ? '일시정지' : '재생'}
onClick={handlePlayPause}
className="shrink-0 p-1"
icon={
isPlaying ? (
<Pause className="w-4 h-4 text-heading" />
) : (
<Play className="w-4 h-4 text-heading" />
)
}
/>
{/* Speed selector */}
<div className="flex items-center gap-1 shrink-0">
{SPEED_OPTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => setSpeed(s)}
className={[
'text-xs px-1.5 py-0.5 rounded font-mono transition-colors',
playbackSpeed === s
? 'bg-primary text-on-vivid font-bold'
: 'text-hint hover:text-label hover:bg-surface-raised',
].join(' ')}
>
{s}x
</button>
))}
</div>
{/* Start time */}
<span className="text-[9px] font-mono text-hint shrink-0">{formatEpochTime(startTime)}</span>
{/* Progress track */}
<div
ref={trackRef}
className="flex-1 h-2 bg-surface-raised rounded-full cursor-pointer relative overflow-hidden min-w-[80px]"
onClick={handleTrackClick}
role="slider"
aria-label="재생 위치"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(initialPct)}
>
{/* 스냅샷 틱마크 */}
{snapshotRanges.map((r, i) => (
<div
key={i}
className="absolute top-0 bottom-0 w-px bg-amber-400/50"
style={{ left: `${r * 100}%` }}
/>
))}
<div
ref={progressBarRef}
className="absolute inset-y-0 left-0 bg-primary rounded-full transition-none"
style={{ width: `${Math.min(100, Math.max(0, initialPct))}%` }}
/>
</div>
{/* End time */}
<span className="text-[9px] font-mono text-hint shrink-0">{formatEpochTime(endTime)}</span>
{/* Current time label */}
<span
ref={timeLabelRef}
className="text-[10px] font-mono text-heading shrink-0 w-[75px] text-center font-bold"
>
{formatEpochTime(useGearReplayStore.getState().currentTime)}
</span>
{/* Close */}
<button
type="button"
aria-label="재생 닫기"
onClick={onClose}
className="shrink-0 p-1 hover:bg-surface-raised rounded"
>
<X className="w-3.5 h-3.5 text-hint" />
</button>
</div>
</div>
);
}

파일 보기

@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}"><polygon points="${s / 2},2 ${s * 0.12},${s - 2} ${s / 2},${s * 0.62} ${s * 0.88},${s - 2}" fill="white"/></svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
})();
const GEAR_URI = (() => {
const s = ICON_SIZE;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}"><polygon points="${s / 2},4 ${s - 4},${s / 2} ${s / 2},${s - 4} 4,${s / 2}" fill="white"/></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<MapboxOverlay | null>,
buildBaseLayers: () => Layer[],
) {
const frameCursorRef = useRef(0);
// iran의 positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색)
const memberCursorsRef = useRef(new Map<string, number>());
// 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<MemberPosition>({
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<MemberPosition>({
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<MemberPosition>({
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<MemberPosition>({
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]);
}

파일 보기

@ -70,6 +70,7 @@ export const BaseMap = memo(forwardRef<MapHandle, BaseMapProps>(function BaseMap
// overlay를 외부에 노출 — useMapLayers hook에서 직접 접근 // overlay를 외부에 노출 — useMapLayers hook에서 직접 접근
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
get overlay() { return overlayRef.current; }, get overlay() { return overlayRef.current; },
get map() { return mapRef.current; },
}), []); }), []);
// 지도 초기화 (1회) // 지도 초기화 (1회)

File diff suppressed because one or more lines are too long

파일 보기

@ -7,9 +7,11 @@
import { useRef, useEffect } from 'react'; import { useRef, useEffect } from 'react';
import type { MapboxOverlay } from '@deck.gl/mapbox'; import type { MapboxOverlay } from '@deck.gl/mapbox';
import type { Layer } from 'deck.gl'; import type { Layer } from 'deck.gl';
import type maplibregl from 'maplibre-gl';
export interface MapHandle { export interface MapHandle {
overlay: MapboxOverlay | null; overlay: MapboxOverlay | null;
map: maplibregl.Map | null;
} }
/** /**
@ -26,6 +28,7 @@ export function useMapLayers(
const prevRef = useRef<unknown[]>([]); const prevRef = useRef<unknown[]>([]);
const rafRef = useRef(0); const rafRef = useRef(0);
// deps 변경 시에만 레이어 갱신 (매 렌더 아닌 deps diff 기반)
useEffect(() => { useEffect(() => {
if (shallowEqual(prevRef.current, deps)) return; if (shallowEqual(prevRef.current, deps)) return;
prevRef.current = deps; prevRef.current = deps;
@ -34,13 +37,16 @@ export function useMapLayers(
rafRef.current = requestAnimationFrame(() => { rafRef.current = requestAnimationFrame(() => {
handleRef.current?.overlay?.setProps({ layers: buildLayers() }); handleRef.current?.overlay?.setProps({ layers: buildLayers() });
}); });
});
// 언마운트 시에만 레이어 초기화 — stale WebGL 참조 방지
useEffect(() => {
return () => { return () => {
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
// 언마운트 시 레이어 초기화 — stale WebGL 참조 방지
try { handleRef.current?.overlay?.setProps({ layers: [] }); } catch { /* finalized */ } try { handleRef.current?.overlay?.setProps({ layers: [] }); } catch { /* finalized */ }
}; };
}); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
} }
/** /**

파일 보기

@ -15,5 +15,7 @@ export {
createHeatmapLayer, createHeatmapLayer,
createZoneLayer, createZoneLayer,
createStaticLayers, createStaticLayers,
createGeoJsonLayer, createGearPolygonLayer,
createShipIconLayer, createGearIconLayer, type ShipIconData, type GearIconData,
} from './layers'; } from './layers';
export { useMapLayers, useStoreLayerSync } from './hooks/useMapLayers'; export { useMapLayers, useStoreLayerSync } from './hooks/useMapLayers';

파일 보기

@ -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<string, unknown>)?.color as string | undefined;
return hexToRgba(color ?? defaultColor, fillOpacity);
},
getLineColor: (f: GeoJSON.Feature) => {
const color = (f.properties as Record<string, unknown>)?.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,
});
}

파일 보기

@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}">
<polygon points="${s / 2},2 ${s * 0.12},${s - 2} ${s / 2},${s * 0.62} ${s * 0.88},${s - 2}" fill="white"/>
</svg>`;
}
function createGearDiamondSvg(): string {
const s = ICON_SIZE;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}">
<polygon points="${s / 2},4 ${s - 4},${s / 2} ${s / 2},${s - 4} 4,${s / 2}" fill="white"/>
</svg>`;
}
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<ShipIconData>({
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<GearIconData>({
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,
});
}

파일 보기

@ -4,3 +4,6 @@ export { createPolylineLayer } from './polyline';
export { createHeatmapLayer } from './heatmap'; export { createHeatmapLayer } from './heatmap';
export { createZoneLayer } from './zones'; export { createZoneLayer } from './zones';
export { createEEZStaticLayer, createNLLStaticLayer, createStaticLayers } from './static'; 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';

파일 보기

@ -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<TripsData>({
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,
});
}

파일 보기

@ -64,10 +64,18 @@ export interface GearGroupItem {
resolution: { resolution: {
status: string; status: string;
selectedParentMmsi: string | null; 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; approvedAt: string | null;
manualComment: string | null; manualComment: string | null;
} | null; } | null;
candidateCount?: number; candidateCount?: number;
liveTopScore?: number; // correlation_scores 실시간 최고 점수
} }
export interface GroupsResponse { export interface GroupsResponse {
@ -86,8 +94,9 @@ export function fetchVesselAnalysis() {
return apiGet<VesselAnalysisResponse>('/vessel-analysis'); return apiGet<VesselAnalysisResponse>('/vessel-analysis');
} }
export function fetchGroups() { export function fetchGroups(groupType?: string) {
return apiGet<GroupsResponse>('/vessel-analysis/groups'); const qs = groupType ? `?groupType=${groupType}` : '';
return apiGet<GroupsResponse>(`/vessel-analysis/groups${qs}`);
} }
export function fetchGroupDetail(groupKey: string) { export function fetchGroupDetail(groupKey: string) {
@ -99,6 +108,74 @@ export function fetchGroupCorrelations(groupKey: string, minScore?: number) {
return apiGet<unknown>(`/vessel-analysis/groups/${encodeURIComponent(groupKey)}/correlations${qs}`); return apiGet<unknown>(`/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<VesselTrack[]> {
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();
}
// ─── 필터/유틸 ───────────────────────────────── // ─── 필터/유틸 ─────────────────────────────────
/** /**

파일 보기

@ -41,6 +41,8 @@ import { CONNECTION_STATUSES } from './connectionStatuses';
import { TRAINING_ZONE_TYPES } from './trainingZoneTypes'; import { TRAINING_ZONE_TYPES } from './trainingZoneTypes';
import { MLOPS_JOB_STATUSES } from './mlopsJobStatuses'; import { MLOPS_JOB_STATUSES } from './mlopsJobStatuses';
import { THREAT_LEVELS, AGENT_PERM_TYPES, AGENT_EXEC_RESULTS } from './aiSecurityStatuses'; import { THREAT_LEVELS, AGENT_PERM_TYPES, AGENT_EXEC_RESULTS } from './aiSecurityStatuses';
import { ZONE_CODES } from './zoneCodes';
import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
/** /**
* UI * UI
@ -297,6 +299,24 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [
source: 'AI Agent 보안 (SER-11)', source: 'AI Agent 보안 (SER-11)',
items: AGENT_EXEC_RESULTS, 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로 특정 카탈로그 조회 */ /** ID로 특정 카탈로그 조회 */

파일 보기

@ -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<GearViolationCode, GearViolationMeta> = {
'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];
}

파일 보기

@ -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<ZoneCode, ZoneCodeMeta> = {
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 ?? [];
}

파일 보기

@ -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<string, {
path: [number, number][];
timestamps: number[];
role: string;
isParent: boolean;
}>();
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<string, MemberMeta> {
const meta = new Map<string, MemberMeta>();
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<string, MemberMeta>,
relativeTimeMs: number,
cursors: Map<string, number>,
): 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;
}

파일 보기

@ -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<string, MemberMeta>;
centerTrailSegments: CenterTrailSegment[];
centerDotsPositions: [number, number][];
snapshotRanges: number[];
dataStartTime: number;
dataEndTime: number;
// 후보 선박 항적
candidateTripsData: TripsLayerDatum[];
candidateMetadata: Map<string, { name: string }>;
// 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<GearReplayState>()(
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<string, { name: string }>();
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,
});
},
})),
);

파일 보기

@ -28,6 +28,9 @@ def classify_vessel_state(sog: float, cog_delta: float = 0.0,
return 'TRANSIT' return 'TRANSIT'
sog_min, sog_max = GEAR_SOG_THRESHOLDS.get(gear_type, (1.0, 5.0)) sog_min, sog_max = GEAR_SOG_THRESHOLDS.get(gear_type, (1.0, 5.0))
if sog_min <= sog <= sog_max: if sog_min <= sog <= sog_max:
# PT(쌍끌이): 공조 시 COG 변화가 적어야 함 — 큰 COG 변화는 비조업
if gear_type == 'PT' and cog_delta > 30.0:
return 'UNKNOWN'
return 'FISHING' return 'FISHING'
return 'UNKNOWN' 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('lat', 0),
records[seg_start_idx].get('lon', 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({ segments.append({
'start_idx': seg_start_idx, 'start_idx': seg_start_idx,
'end_idx': i - 1, 'end_idx': seg_end,
'duration_min': round(dur_min, 1), 'duration_min': round(dur_min, 1),
'zone': zone_info.get('zone', 'UNKNOWN'), 'zone': zone_info.get('zone', 'UNKNOWN'),
'in_territorial_sea': zone_info.get('zone') == 'TERRITORIAL_SEA', 'in_territorial_sea': zone_info.get('zone') == 'TERRITORIAL_SEA',
'speed_anomaly_count': speed_anomalies,
}) })
in_fishing = False 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('lat', 0),
records[seg_start_idx].get('lon', 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({ segments.append({
'start_idx': seg_start_idx, 'start_idx': seg_start_idx,
'end_idx': len(records) - 1, 'end_idx': seg_end,
'duration_min': round(dur_min, 1), 'duration_min': round(dur_min, 1),
'zone': zone_info.get('zone', 'UNKNOWN'), 'zone': zone_info.get('zone', 'UNKNOWN'),
'in_territorial_sea': zone_info.get('zone') == 'TERRITORIAL_SEA', 'in_territorial_sea': zone_info.get('zone') == 'TERRITORIAL_SEA',
'speed_anomaly_count': speed_anomalies,
}) })
return segments return segments

파일 보기

@ -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,
}

파일 보기

@ -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() 회피.
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

파일 보기

@ -78,7 +78,8 @@ def upsert_results(results: list['AnalysisResult']) -> int:
fleet_cluster_id, fleet_is_leader, fleet_role, fleet_cluster_id, fleet_is_leader, fleet_role,
risk_score, risk_level, risk_score, risk_level,
transship_suspect, transship_pair_mmsi, transship_duration_min, transship_suspect, transship_pair_mmsi, transship_duration_min,
features features,
gear_judgment
) VALUES %s ) VALUES %s
ON CONFLICT (mmsi, analyzed_at) DO UPDATE SET ON CONFLICT (mmsi, analyzed_at) DO UPDATE SET
vessel_type = EXCLUDED.vessel_type, vessel_type = EXCLUDED.vessel_type,
@ -104,7 +105,8 @@ def upsert_results(results: list['AnalysisResult']) -> int:
transship_suspect = EXCLUDED.transship_suspect, transship_suspect = EXCLUDED.transship_suspect,
transship_pair_mmsi = EXCLUDED.transship_pair_mmsi, transship_pair_mmsi = EXCLUDED.transship_pair_mmsi,
transship_duration_min = EXCLUDED.transship_duration_min, transship_duration_min = EXCLUDED.transship_duration_min,
features = EXCLUDED.features features = EXCLUDED.features,
gear_judgment = EXCLUDED.gear_judgment
""" """
try: try:

파일 보기

@ -83,6 +83,26 @@ class FleetTracker:
len(self._vessels), 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: def match_ais_to_registry(self, ais_vessels: list[dict], conn) -> None:
"""AIS 선박을 등록 선단에 매칭. DB 업데이트. """AIS 선박을 등록 선단에 매칭. DB 업데이트.

파일 보기

@ -161,3 +161,64 @@ def get_correlation_tracks(
except Exception as e: except Exception as e:
logger.warning('get_correlation_tracks failed for %s: %s', group_key, e) logger.warning('get_correlation_tracks failed for %s: %s', group_key, e)
return {'groupKey': group_key, 'vessels': []} 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': []}

파일 보기

@ -5,7 +5,7 @@ from typing import Optional
@dataclass @dataclass
class AnalysisResult: class AnalysisResult:
"""vessel_analysis_results 테이블 28컬럼 매핑.""" """vessel_analysis_results 테이블 29컬럼 매핑."""
mmsi: str mmsi: str
timestamp: datetime timestamp: datetime
@ -52,6 +52,9 @@ class AnalysisResult:
# 특징 벡터 # 특징 벡터
features: dict = field(default_factory=dict) features: dict = field(default_factory=dict)
# ALGO 09: 어구 위반 판정
gear_judgment: str = ''
# 메타 # 메타
analyzed_at: Optional[datetime] = None analyzed_at: Optional[datetime] = None
@ -116,4 +119,5 @@ class AnalysisResult:
str(self.transship_pair_mmsi), str(self.transship_pair_mmsi),
_i(self.transship_duration_min), _i(self.transship_duration_min),
json.dumps(safe_features), json.dumps(safe_features),
str(self.gear_judgment) if self.gear_judgment else None,
) )

파일 보기

@ -140,6 +140,41 @@ RULES = [
'category': 'HIGH_RISK_VESSEL', 'category': 'HIGH_RISK_VESSEL',
'title_fn': lambda r: f"위험 행동 패턴 (위험도 {r.get('risk_score', 0)})", '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): 조류보정 초과 이동",
},
] ]

파일 보기

@ -60,7 +60,7 @@ def classify_violations(result: dict) -> list[str]:
# 어구 불법 (gear_judgment이 있는 경우만 — 현재는 scheduler에서 채우지 않음) # 어구 불법 (gear_judgment이 있는 경우만 — 현재는 scheduler에서 채우지 않음)
gear_judgment = result.get('gear_judgment', '') or '' 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') violations.append('ILLEGAL_GEAR')
# 위험 행동 (다른 위반 없이 고위험) # 위험 행동 (다른 위반 없이 고위험)

파일 보기

@ -225,8 +225,12 @@ def run_analysis_cycle():
zone_info = classify_zone(last_row['lat'], last_row['lon']) zone_info = classify_zone(last_row['lat'], last_row['lon'])
gear_map = {'TRAWL': 'OT', 'PURSE': 'PS', 'LONGLINE': 'GN', 'TRAP': 'TRAP'} gear_map = {'TRAWL': 'OT', 'PT': 'PT', 'PURSE': 'PS', 'LONGLINE': 'GN', 'TRAP': 'TRAP'}
gear = gear_map.get(c['vessel_type'], 'OT') # 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) ucaf = compute_ucaf_score(df_v, gear)
ucft = compute_ucft_score(df_v) 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: if 'state' in df_v.columns and len(df_v) > 0:
activity = df_v['state'].mode().iloc[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( results.append(AnalysisResult(
mmsi=mmsi, mmsi=mmsi,
timestamp=ts, timestamp=ts,
vessel_type=c['vessel_type'], vessel_type=vtype,
confidence=c['confidence'], confidence=c['confidence'],
fishing_pct=c['fishing_pct'], fishing_pct=c['fishing_pct'],
cluster_id=fleet_info.get('cluster_id', -1), cluster_id=fleet_info.get('cluster_id', -1),
@ -310,9 +364,10 @@ def run_analysis_cycle():
cluster_size=fleet_info.get('cluster_size', 0), cluster_size=fleet_info.get('cluster_size', 0),
is_leader=fleet_info.get('is_leader', False), is_leader=fleet_info.get('is_leader', False),
fleet_role=fleet_info.get('fleet_role', 'NOISE'), fleet_role=fleet_info.get('fleet_role', 'NOISE'),
risk_score=risk_score, risk_score=final_risk,
risk_level=risk_level, risk_level=final_risk_level,
features=merged_features, features=merged_features,
gear_judgment=gear_judgment,
)) ))
logger.info( logger.info(

파일 보기

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# prediction 알고리즘 진단 스냅샷 수집기 (5분 주기, 수동 종료까지 연속 실행) # prediction 알고리즘 진단 스냅샷 수집기 (5분 주기, 수동 종료까지 연속 실행)
# #
# 용도: 알고리즘 재설계 후 동작 검증용. 단순 집계가 아닌 개별 판정 과정 추적. # 용도: DAR-03 G코드 + 쌍끌이 + 어구 위반 포함 알고리즘 동작 검증
# 실행: nohup bash /home/apps/kcg-ai-prediction/scripts/diagnostic-snapshot.sh & # 실행: nohup bash /home/apps/kcg-ai-prediction/scripts/diagnostic-snapshot.sh &
# 종료: kill $(cat /home/apps/kcg-ai-prediction/data/diag/diag.pid) # 종료: kill $(cat /home/apps/kcg-ai-prediction/data/diag/diag.pid)
# 출력: /home/apps/kcg-ai-prediction/data/diag/YYYYMMDD-HHMM.txt # 출력: /home/apps/kcg-ai-prediction/data/diag/YYYYMMDD-HHMM.txt
@ -25,7 +25,7 @@ OUT="$OUTDIR/$STAMP.txt"
{ {
echo "###################################################################" echo "###################################################################"
echo "# PREDICTION DIAGNOSTIC SNAPSHOT" echo "# PREDICTION DIAGNOSTIC SNAPSHOT (DAR-03 enhanced)"
echo "# generated: $(date '+%Y-%m-%d %H:%M:%S %Z')" echo "# generated: $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo "# host: $(hostname)" echo "# host: $(hostname)"
echo "# interval: ${INTERVAL_SEC}s" echo "# interval: ${INTERVAL_SEC}s"
@ -45,6 +45,7 @@ SELECT count(*) total,
count(*) FILTER (WHERE vessel_type = 'UNKNOWN') lightweight, count(*) FILTER (WHERE vessel_type = 'UNKNOWN') lightweight,
count(*) FILTER (WHERE is_dark) dark, count(*) FILTER (WHERE is_dark) dark,
count(*) FILTER (WHERE transship_suspect) transship, 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='CRITICAL') crit,
count(*) FILTER (WHERE risk_level='HIGH') high, count(*) FILTER (WHERE risk_level='HIGH') high,
round(avg(risk_score)::numeric, 1) avg_risk, round(avg(risk_score)::numeric, 1) avg_risk,
@ -83,7 +84,7 @@ GROUP BY bucket ORDER BY bucket;
SQL SQL
echo "" echo ""
echo "--- 2-2. dark_patterns 발동 빈도 (어떤 규칙이 얼마나 적용되는지) ---" echo "--- 2-2. dark_patterns 발동 빈도 ---"
$PSQL_TABLE << 'SQL' $PSQL_TABLE << 'SQL'
SELECT pattern, SELECT pattern,
count(*) cnt, count(*) cnt,
@ -97,72 +98,24 @@ GROUP BY pattern ORDER BY cnt DESC;
SQL SQL
echo "" echo ""
echo "--- 2-3. P9 선종별 dark 분포 (신규 패턴 검증) ---" echo "--- 2-3. P9/P10/P11 + coverage 요약 ---"
$PSQL_TABLE << 'SQL' $PSQL_TABLE << 'SQL'
SELECT WITH dark AS (
CASE WHEN features->>'dark_patterns' LIKE '%fishing_vessel_dark%' THEN 'FISHING(+10)' SELECT features FROM kcg.vessel_analysis_results
WHEN features->>'dark_patterns' LIKE '%cargo_natural_gap%' THEN 'CARGO(-10)' WHERE analyzed_at > now() - interval '5 minutes' AND is_dark = true
ELSE 'NO_KIND_EFFECT' END AS p9_effect, )
count(*) cnt, SELECT count(*) total_dark,
round(avg((features->>'dark_suspicion_score')::int)::numeric, 1) avg_score, count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%fishing_vessel_dark%'
round(avg(gap_duration_min)::numeric, 0) avg_gap OR features->>'dark_patterns' LIKE '%cargo_natural_gap%') p9,
FROM kcg.vessel_analysis_results count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%underway_deliberate_off%'
WHERE analyzed_at > now() - interval '5 minutes' OR features->>'dark_patterns' LIKE '%anchored_natural_gap%') p10,
AND is_dark = true count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%heading_cog_mismatch%') p11,
GROUP BY p9_effect ORDER BY cnt DESC; count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%out_of_coverage%') coverage
FROM dark;
SQL SQL
echo "" echo ""
echo "--- 2-4. P10 항해상태 dark 분포 (신규 패턴 검증) ---" echo "--- 2-4. CRITICAL dark 상위 10건 ---"
$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건 (개별 판정 상세) ---"
$PSQL_TABLE << 'SQL' $PSQL_TABLE << 'SQL'
SELECT mmsi, gap_duration_min, zone_code, activity_state, SELECT mmsi, gap_duration_min, zone_code, activity_state,
(features->>'dark_suspicion_score')::int AS score, (features->>'dark_suspicion_score')::int AS score,
@ -177,59 +130,152 @@ LIMIT 10;
SQL SQL
#=================================================================== #===================================================================
# PART 3: 환적 탐지 심층 진단 # PART 3: 환적 탐지
#=================================================================== #===================================================================
echo "" echo ""
echo "=================================================================" echo "================================================================="
echo "PART 3: TRANSSHIPMENT 심층 진단" echo "PART 3: TRANSSHIPMENT 진단"
echo "=================================================================" echo "================================================================="
echo ""
echo "--- 3-1. 환적 의심 건수 + 점수 분포 ---"
$PSQL_TABLE << 'SQL' $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 >= 70) critical,
count(*) FILTER (WHERE (features->>'transship_score')::numeric >= 50 count(*) FILTER (WHERE (features->>'transship_score')::numeric >= 50
AND (features->>'transship_score')::numeric < 70) high, AND (features->>'transship_score')::numeric < 70) high,
round(avg((features->>'transship_score')::numeric)::numeric, 1) avg_score, 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 FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes' WHERE analyzed_at > now() - interval '5 minutes' AND transship_suspect = true;
AND transship_suspect = true;
SQL SQL
echo "" echo ""
echo "--- 3-2. 환적 의심 개별 건 상세 (전체) ---" echo "--- 3-1. 환적 의심 개별 건 ---"
$PSQL_TABLE << 'SQL' $PSQL_TABLE << 'SQL'
SELECT mmsi, transship_pair_mmsi AS pair_mmsi, SELECT mmsi, transship_pair_mmsi pair, transship_duration_min dur,
transship_duration_min AS dur_min, (features->>'transship_score')::numeric score,
(features->>'transship_score')::numeric AS score, features->>'transship_tier' tier, zone_code
features->>'transship_tier' AS tier,
zone_code,
activity_state,
risk_score
FROM kcg.vessel_analysis_results FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes' WHERE analyzed_at > now() - interval '5 minutes' AND transship_suspect = true
AND transship_suspect = true
ORDER BY (features->>'transship_score')::numeric DESC; ORDER BY (features->>'transship_score')::numeric DESC;
SQL 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 "=================================================================" echo "================================================================="
echo "PART 4: 이벤트 + KPI (시스템 출력 검증)" echo "PART 4: G코드 어구 위반 진단 (DAR-03)"
echo "=================================================================" echo "================================================================="
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' $PSQL_TABLE << 'SQL'
SELECT category, level, count(*) cnt SELECT category, level, count(*) cnt
FROM kcg.prediction_events FROM kcg.prediction_events
@ -237,77 +283,37 @@ WHERE created_at > now() - interval '5 minutes'
GROUP BY category, level ORDER BY cnt DESC; GROUP BY category, level ORDER BY cnt DESC;
SQL SQL
echo ""
echo "--- 4-2. KPI 실시간 ---"
$PSQL_TABLE << 'SQL' $PSQL_TABLE << 'SQL'
SELECT kpi_key, value, trend, delta_pct, updated_at SELECT kpi_key, value, trend, delta_pct, updated_at
FROM kcg.prediction_kpi_realtime ORDER BY kpi_key; FROM kcg.prediction_kpi_realtime ORDER BY kpi_key;
SQL SQL
#=================================================================== #===================================================================
# PART 5: signal-batch 정적정보 보강 검증 # PART 7: 사이클 로그 + 에러
#=================================================================== #===================================================================
echo "" echo ""
echo "=================================================================" echo "================================================================="
echo "PART 5: signal-batch 정적정보 보강 검증" echo "PART 7: 사이클 로그 (최근 6분)"
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 "=================================================================" echo "================================================================="
journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \ 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 tail -20
#=================================================================== #===================================================================
# PART 7: 해역별 + 위험도 교차 (운영 지표) # PART 8: 해역별 종합 교차
#=================================================================== #===================================================================
echo "" echo ""
echo "=================================================================" echo "================================================================="
echo "PART 7: 해역별 × 위험도 교차표" echo "PART 8: 해역별 종합 교차표"
echo "=================================================================" echo "================================================================="
$PSQL_TABLE << 'SQL' $PSQL_TABLE << 'SQL'
SELECT zone_code, SELECT zone_code, count(*) total,
count(*) total,
count(*) FILTER (WHERE is_dark) dark, 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='CRITICAL') crit,
count(*) FILTER (WHERE risk_level='HIGH') high, count(*) FILTER (WHERE risk_level='HIGH') high,
round(avg(risk_score)::numeric, 1) avg_risk, round(avg(risk_score)::numeric, 1) avg_risk
count(*) FILTER (WHERE transship_suspect) transship
FROM kcg.vessel_analysis_results FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes' WHERE analyzed_at > now() - interval '5 minutes'
GROUP BY zone_code ORDER BY total DESC; GROUP BY zone_code ORDER BY total DESC;

파일 보기

@ -1,20 +1,9 @@
#!/bin/bash #!/bin/bash
# prediction 시간당 상태 스냅샷 수집기 # prediction 시간당 상태 스냅샷 수집기 (DAR-03 G코드 + 어구 위반 포함)
# 실행 환경: redis-211 서버 (prediction 서비스 호스트) # 실행 환경: redis-211 서버 (prediction 서비스 호스트)
# cron: 0 * * * * /home/apps/kcg-ai-prediction/scripts/hourly-analysis-snapshot.sh # cron: 0 * * * * /home/apps/kcg-ai-prediction/scripts/hourly-analysis-snapshot.sh
# #
# 출력: /home/apps/kcg-ai-prediction/data/hourly-analysis/YYYYMMDD-HHMM.txt # 출력: /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 set -u
@ -27,7 +16,7 @@ export PGPASSWORD=Kcg2026ai
PSQL="psql -U kcg-app -d kcgaidb -h 211.208.115.83 -P pager=off" 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 "# generated: $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo "# host: $(hostname)" echo "# host: $(hostname)"
echo "" echo ""
@ -39,21 +28,22 @@ SELECT count(*) total,
count(*) FILTER (WHERE vessel_type = 'UNKNOWN') lightweight_path, count(*) FILTER (WHERE vessel_type = 'UNKNOWN') lightweight_path,
count(*) FILTER (WHERE is_dark) dark, count(*) FILTER (WHERE is_dark) dark,
count(*) FILTER (WHERE spoofing_score > 0.5) spoof_hi, count(*) FILTER (WHERE spoofing_score > 0.5) spoof_hi,
count(*) FILTER (WHERE spoofing_score > 0) spoof_any, count(*) FILTER (WHERE transship_suspect) transship,
count(*) FILTER (WHERE risk_score >= 70) crit_score, 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='CRITICAL') crit_lvl,
count(*) FILTER (WHERE risk_level='HIGH') high_lvl, count(*) FILTER (WHERE risk_level='HIGH') high_lvl,
max(risk_score) max_risk, max(risk_score) max_risk,
round(avg(risk_score)::numeric, 2) avg_risk, round(avg(risk_score)::numeric, 2) avg_risk
count(*) FILTER (WHERE transship_suspect) transship
FROM kcg.vessel_analysis_results FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '1 hour'; WHERE analyzed_at > now() - interval '1 hour';
\echo \echo
\echo === 2. ZONE × DARK distribution === \echo === 2. ZONE x DARK x GEAR_VIOLATION distribution ===
SELECT zone_code, SELECT zone_code,
count(*) total, count(*) total,
count(*) FILTER (WHERE is_dark) dark, 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, count(*) FILTER (WHERE risk_score >= 70) crit,
round(avg(risk_score)::numeric, 1) avg_risk round(avg(risk_score)::numeric, 1) avg_risk
FROM kcg.vessel_analysis_results FROM kcg.vessel_analysis_results
@ -61,7 +51,7 @@ WHERE analyzed_at > now() - interval '1 hour'
GROUP BY zone_code ORDER BY total DESC; GROUP BY zone_code ORDER BY total DESC;
\echo \echo
\echo === 3. DARK GAP distribution (all vessels in last 1h) === \echo === 3. DARK GAP distribution ===
SELECT CASE SELECT CASE
WHEN gap_duration_min < 30 THEN 'a_lt30' WHEN gap_duration_min < 30 THEN 'a_lt30'
WHEN gap_duration_min < 60 THEN 'b_30-59' WHEN gap_duration_min < 60 THEN 'b_30-59'
@ -71,21 +61,21 @@ SELECT CASE
ELSE 'f_gte1440' END gap_bucket, ELSE 'f_gte1440' END gap_bucket,
count(*) total, count(*) total,
count(*) FILTER (WHERE is_dark) dark, 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 count(*) FILTER (WHERE is_dark AND vessel_type!='UNKNOWN') dark_pipeline
FROM kcg.vessel_analysis_results FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '1 hour' WHERE analyzed_at > now() - interval '1 hour'
GROUP BY gap_bucket ORDER BY gap_bucket; GROUP BY gap_bucket ORDER BY gap_bucket;
\echo \echo
\echo === 4. DARK vessels by activity_state === \echo === 4. DARK by activity_state ===
SELECT activity_state, count(*), round(avg(gap_duration_min)::numeric, 0) avg_gap_min SELECT activity_state, count(*), round(avg(gap_duration_min)::numeric, 0) avg_gap
FROM kcg.vessel_analysis_results FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '1 hour' AND is_dark WHERE analyzed_at > now() - interval '1 hour' AND is_dark
GROUP BY activity_state ORDER BY count DESC; GROUP BY activity_state ORDER BY count DESC;
\echo \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 SELECT mmsi, zone_code, activity_state, gap_duration_min, risk_score
FROM ( FROM (
SELECT DISTINCT ON (mmsi) mmsi, zone_code, activity_state, gap_duration_min, SELECT DISTINCT ON (mmsi) mmsi, zone_code, activity_state, gap_duration_min,
@ -93,11 +83,10 @@ FROM (
FROM kcg.vessel_analysis_results FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '1 hour' AND is_dark WHERE analyzed_at > now() - interval '1 hour' AND is_dark
ORDER BY mmsi, analyzed_at DESC ORDER BY mmsi, analyzed_at DESC
) latest ) latest ORDER BY gap_duration_min DESC LIMIT 20;
ORDER BY gap_duration_min DESC LIMIT 20;
\echo \echo
\echo === 6. PREDICTION_EVENTS last 1h by category×level === \echo === 6. EVENTS last 1h by category x level ===
SELECT category, level, count(*) cnt SELECT category, level, count(*) cnt
FROM kcg.prediction_events FROM kcg.prediction_events
WHERE created_at > now() - interval '1 hour' 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; FROM kcg.prediction_kpi_realtime ORDER BY kpi_key;
\echo \echo
\echo === 9. RISK_SCORE histogram (last 1h) === \echo === 9. RISK_SCORE histogram ===
SELECT CASE SELECT CASE
WHEN risk_score < 10 THEN 'a_0-9' WHEN risk_score < 10 THEN 'a_0-9'
WHEN risk_score < 30 THEN 'b_10-29' 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; GROUP BY bucket ORDER BY bucket;
\echo \echo
\echo === 10. TRANSSHIP, SPOOFING, FLEET 요약 === \echo === 10. TRANSSHIP + SPOOF + FLEET ===
SELECT SELECT
count(*) FILTER (WHERE transship_suspect) transship_ct, count(*) FILTER (WHERE transship_suspect) transship_ct,
count(*) FILTER (WHERE spoofing_score > 0.7) spoof_gt070, 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 speed_jump_count > 0) speed_jumps,
count(*) FILTER (WHERE fleet_is_leader) fleet_leader, 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 FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '1 hour'; WHERE analyzed_at > now() - interval '1 hour';
\echo \echo
\echo === 10-1. FLEET_ROLE distribution === \echo === G1. PIPELINE vessel_type distribution ===
SELECT fleet_role, count(*), count(DISTINCT mmsi) uniq_mmsi SELECT vessel_type, count(*),
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,
round(avg(ucaf_score)::numeric, 3) avg_ucaf, round(avg(ucaf_score)::numeric, 3) avg_ucaf,
round(avg(ucft_score)::numeric, 3) avg_ucft, round(avg(ucft_score)::numeric, 3) avg_ucft,
round(avg(risk_score)::numeric, 1) avg_risk 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; GROUP BY vessel_type ORDER BY count DESC;
\echo \echo
\echo === G2. ACTIVITY_STATE distribution (전체) === \echo === G2. GEAR_GROUP_PARENT_RESOLUTION ===
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 ===
SELECT status, count(*), SELECT status, count(*),
round(avg(confidence)::numeric, 3) avg_conf, round(avg(confidence)::numeric, 3) avg_conf,
round(avg(top_score)::numeric, 3) avg_top, round(avg(top_score)::numeric, 3) avg_top,
round(avg(score_margin)::numeric, 3) avg_margin,
round(avg(stable_cycles)::numeric, 1) avg_stable round(avg(stable_cycles)::numeric, 1) avg_stable
FROM kcg.gear_group_parent_resolution FROM kcg.gear_group_parent_resolution
GROUP BY status ORDER BY count DESC; GROUP BY status ORDER BY count DESC;
\echo \echo
\echo === G3-1. PARENT_RESOLUTION decision_source === \echo === G3. GEAR_CORRELATION_SCORES distribution ===
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) 분포 ===
SELECT CASE SELECT CASE
WHEN current_score < 0.3 THEN 'a_lt0.3' WHEN current_score < 0.3 THEN 'a_lt0.3'
WHEN current_score < 0.5 THEN 'b_0.3-0.5' WHEN current_score < 0.5 THEN 'b_0.3-0.5'
@ -220,117 +159,119 @@ SELECT CASE
ELSE 'e_gte0.85' END bucket, ELSE 'e_gte0.85' END bucket,
count(*), count(*),
count(DISTINCT group_key) uniq_groups, count(DISTINCT group_key) uniq_groups,
count(DISTINCT target_mmsi) uniq_targets, count(DISTINCT target_mmsi) uniq_targets
round(avg(streak_count)::numeric, 1) avg_streak
FROM kcg.gear_correlation_scores FROM kcg.gear_correlation_scores
WHERE updated_at > now() - interval '1 hour' WHERE updated_at > now() - interval '1 hour'
GROUP BY bucket ORDER BY bucket; GROUP BY bucket ORDER BY bucket;
\echo \echo
\echo === G5-1. CORRELATION freeze_state === \echo ===================================================================
SELECT freeze_state, count(*), round(avg(current_score)::numeric, 3) avg_score \echo === DAR-03 G-CODE DIAGNOSTICS (last 1h)
FROM kcg.gear_correlation_scores \echo ===================================================================
WHERE updated_at > now() - interval '1 hour'
GROUP BY freeze_state ORDER BY count DESC;
\echo \echo
\echo === G6. GROUP_POLYGON_SNAPSHOTS (last 1h, by type × zone) === \echo === D1. gear_judgment distribution ===
SELECT group_type, SELECT coalesce(NULLIF(gear_judgment, ''), '(none)') judgment,
coalesce(zone_id, '(null)') zone, count(*) cnt,
count(*), round(avg(risk_score)::numeric, 1) avg_risk
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
FROM kcg.vessel_analysis_results 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
\echo === G8. VIOLATION_CATEGORIES (last 1h, unnest) === \echo === D2. G-code frequency ===
SELECT unnest(violation_categories) vcat, count(*) 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 FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '1 hour' AND violation_categories IS NOT NULL WHERE analyzed_at > now() - interval '1 hour'
GROUP BY vcat ORDER BY count DESC LIMIT 20; AND vessel_type != 'UNKNOWN' AND zone_code LIKE 'ZONE_%'
GROUP BY zone_code, vessel_type ORDER BY zone_code, vessel_type;
\echo \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, SELECT date_trunc('hour', occurred_at AT TIME ZONE 'Asia/Seoul') hr,
count(*) tot, count(*) tot,
count(*) FILTER (WHERE category='DARK_VESSEL') dark, count(*) FILTER (WHERE category='DARK_VESSEL') dark,
count(*) FILTER (WHERE category='ILLEGAL_TRANSSHIP') transship, count(*) FILTER (WHERE category='ILLEGAL_TRANSSHIP') transship,
count(*) FILTER (WHERE category='EEZ_INTRUSION') eez, 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='HIGH_RISK_VESSEL') high_risk,
count(*) FILTER (WHERE category='ZONE_DEPARTURE') zone_dep,
count(*) FILTER (WHERE level='CRITICAL') critical count(*) FILTER (WHERE level='CRITICAL') critical
FROM kcg.prediction_events FROM kcg.prediction_events
WHERE created_at > now() - interval '24 hours' WHERE created_at > now() - interval '24 hours'
GROUP BY hr ORDER BY hr DESC LIMIT 25; 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 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 ""
echo "=== 13. CYCLE LOG (last 65 min) ===" echo "=== 13. CYCLE LOG (last 65 min) ==="
journalctl -u kcg-ai-prediction --since '65 minutes ago' --no-pager 2>/dev/null | \ 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 tail -60
echo "" echo ""