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:
부모
359eebe200
커밋
2ee8a0e7ff
1
.gitignore
vendored
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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
641
frontend/src/features/detection/components/GearDetailPanel.tsx
Normal file
641
frontend/src/features/detection/components/GearDetailPanel.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
402
frontend/src/hooks/useGearReplayLayers.ts
Normal file
402
frontend/src/hooks/useGearReplayLayers.ts
Normal file
@ -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회)
|
||||||
|
|||||||
1
frontend/src/lib/map/data/fishing-zones-wgs84.json
Normal file
1
frontend/src/lib/map/data/fishing-zones-wgs84.json
Normal file
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';
|
||||||
|
|||||||
82
frontend/src/lib/map/layers/geojson.ts
Normal file
82
frontend/src/lib/map/layers/geojson.ts
Normal file
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
121
frontend/src/lib/map/layers/icons.ts
Normal file
121
frontend/src/lib/map/layers/icons.ts
Normal file
@ -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';
|
||||||
|
|||||||
42
frontend/src/lib/map/layers/trips.ts
Normal file
42
frontend/src/lib/map/layers/trips.ts
Normal file
@ -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로 특정 카탈로그 조회 */
|
||||||
|
|||||||
93
frontend/src/shared/constants/gearViolationCodes.ts
Normal file
93
frontend/src/shared/constants/gearViolationCodes.ts
Normal file
@ -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];
|
||||||
|
}
|
||||||
95
frontend/src/shared/constants/zoneCodes.ts
Normal file
95
frontend/src/shared/constants/zoneCodes.ts
Normal file
@ -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 ?? [];
|
||||||
|
}
|
||||||
419
frontend/src/stores/gearReplayPreprocess.ts
Normal file
419
frontend/src/stores/gearReplayPreprocess.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
241
frontend/src/stores/gearReplayStore.ts
Normal file
241
frontend/src/stores/gearReplayStore.ts
Normal file
@ -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
|
||||||
|
|||||||
265
prediction/algorithms/gear_violation.py
Normal file
265
prediction/algorithms/gear_violation.py
Normal file
@ -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,
|
||||||
|
}
|
||||||
383
prediction/algorithms/pair_trawl.py
Normal file
383
prediction/algorithms/pair_trawl.py
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
"""쌍끌이 트롤 공조 탐지 — DAR-03 G-06.
|
||||||
|
|
||||||
|
두 선박의 AIS 궤적을 분석하여 쌍끌이 조업 여부를 판정한다.
|
||||||
|
판정 기준 (DAR-03 / Kroodsma 2018):
|
||||||
|
- 선박 간격 ≤ 500m (0.27 NM)
|
||||||
|
- 속력 차이 ≤ 0.5 kn
|
||||||
|
- 방향 차이 ≤ 10°
|
||||||
|
- 조업 속력 2.0~4.0 kn
|
||||||
|
- 지속 시간 ≥ 2시간
|
||||||
|
- 동시 AIS 차단 ≥ 30분 → P-01 추가
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
try:
|
||||||
|
from algorithms.location import haversine_nm
|
||||||
|
except ImportError:
|
||||||
|
def haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float: # type: ignore[misc]
|
||||||
|
"""두 좌표 간 거리 (해리). fallback 구현."""
|
||||||
|
R = 3440.065
|
||||||
|
dlat = math.radians(lat2 - lat1)
|
||||||
|
dlon = math.radians(lon2 - lon1)
|
||||||
|
a = (math.sin(dlat / 2) ** 2
|
||||||
|
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2))
|
||||||
|
* math.sin(dlon / 2) ** 2)
|
||||||
|
return 2 * R * math.asin(math.sqrt(a))
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 상수
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PROXIMITY_NM = 0.27 # 500m ≈ 0.27 NM
|
||||||
|
SOG_DELTA_MAX = 0.5 # kn
|
||||||
|
COG_DELTA_MAX = 10.0 # degrees
|
||||||
|
SOG_MIN = 2.0 # kn (조업 속력 하한)
|
||||||
|
SOG_MAX = 4.0 # kn (조업 속력 상한)
|
||||||
|
MIN_SYNC_CYCLES = 24 # 24 × 5min = 2시간
|
||||||
|
SIMULTANEOUS_GAP_MIN = 30 # 동시 AIS 차단 기준 (분)
|
||||||
|
CYCLE_INTERVAL_MIN = 5 # 5분 리샘플 데이터
|
||||||
|
|
||||||
|
# scan_unregistered_pairs 전용
|
||||||
|
CELL_SIZE = 0.01 # ~1.1km 격자
|
||||||
|
CANDIDATE_PROXIMITY_FACTOR = 2.0 # 후보 탐색 반경: PROXIMITY_NM × 2
|
||||||
|
CANDIDATE_SOG_MIN = 1.5 # 후보 속력 하한 (완화)
|
||||||
|
CANDIDATE_SOG_MAX = 5.0 # 후보 속력 상한 (완화)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 내부 헬퍼
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _cog_delta(cog_a: float, cog_b: float) -> float:
|
||||||
|
"""두 COG 값의 각도 차이 (0~180°)."""
|
||||||
|
return abs((cog_a - cog_b + 180.0) % 360.0 - 180.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_gaps(df: pd.DataFrame, gap_threshold_min: float = 10.0) -> list[tuple[pd.Timestamp, pd.Timestamp]]:
|
||||||
|
"""DataFrame 행 간 gap_threshold_min 초과 gap 구간 목록 반환.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of (gap_start, gap_end) as pd.Timestamp pairs
|
||||||
|
"""
|
||||||
|
if len(df) < 2:
|
||||||
|
return []
|
||||||
|
ts_series = pd.to_datetime(df['timestamp']).sort_values().reset_index(drop=True)
|
||||||
|
gaps: list[tuple[pd.Timestamp, pd.Timestamp]] = []
|
||||||
|
for i in range(1, len(ts_series)):
|
||||||
|
delta_min = (ts_series.iloc[i] - ts_series.iloc[i - 1]).total_seconds() / 60.0
|
||||||
|
if delta_min > gap_threshold_min:
|
||||||
|
gaps.append((ts_series.iloc[i - 1], ts_series.iloc[i]))
|
||||||
|
return gaps
|
||||||
|
|
||||||
|
|
||||||
|
def _overlap_minutes(
|
||||||
|
gaps_a: list[tuple[pd.Timestamp, pd.Timestamp]],
|
||||||
|
gaps_b: list[tuple[pd.Timestamp, pd.Timestamp]],
|
||||||
|
) -> float:
|
||||||
|
"""두 gap 목록의 시간 구간 겹침 합계 (분)."""
|
||||||
|
total = 0.0
|
||||||
|
for start_a, end_a in gaps_a:
|
||||||
|
for start_b, end_b in gaps_b:
|
||||||
|
overlap_start = max(start_a, start_b)
|
||||||
|
overlap_end = min(end_a, end_b)
|
||||||
|
if overlap_end > overlap_start:
|
||||||
|
total += (overlap_end - overlap_start).total_seconds() / 60.0
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _max_sync_block(synced_series: 'pd.Series[bool]') -> int:
|
||||||
|
"""연속 True 블록의 최대 길이 반환."""
|
||||||
|
max_block = 0
|
||||||
|
current = 0
|
||||||
|
for val in synced_series:
|
||||||
|
if val:
|
||||||
|
current += 1
|
||||||
|
max_block = max(max_block, current)
|
||||||
|
else:
|
||||||
|
current = 0
|
||||||
|
return max_block
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_key(lat: float, lon: float) -> tuple[int, int]:
|
||||||
|
return (round(lat / CELL_SIZE), round(lon / CELL_SIZE))
|
||||||
|
|
||||||
|
|
||||||
|
def _default_result(mmsi_b: str) -> dict:
|
||||||
|
return {
|
||||||
|
'pair_detected': False,
|
||||||
|
'sync_duration_min': 0.0,
|
||||||
|
'max_sync_block_min': 0.0,
|
||||||
|
'mean_separation_nm': 0.0,
|
||||||
|
'sog_delta_mean': 0.0,
|
||||||
|
'cog_delta_mean': 0.0,
|
||||||
|
'simultaneous_gap_min': 0.0,
|
||||||
|
'g_codes': [],
|
||||||
|
'confidence': 0.0,
|
||||||
|
'pair_mmsi': mmsi_b,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 공개 API
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def detect_pair_trawl(
|
||||||
|
df_a: pd.DataFrame,
|
||||||
|
df_b: pd.DataFrame,
|
||||||
|
mmsi_a: str,
|
||||||
|
mmsi_b: str,
|
||||||
|
) -> dict:
|
||||||
|
"""쌍끌이 트롤 공조 탐지 (DAR-03 G-06).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df_a: 선박 A의 AIS DataFrame. 필수 컬럼: timestamp, lat, lon, sog, cog
|
||||||
|
df_b: 선박 B의 AIS DataFrame. 필수 컬럼: timestamp, lat, lon, sog, cog
|
||||||
|
mmsi_a: 선박 A MMSI
|
||||||
|
mmsi_b: 선박 B MMSI (결과의 pair_mmsi에 기록)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
'pair_detected': bool,
|
||||||
|
'sync_duration_min': float,
|
||||||
|
'max_sync_block_min': float,
|
||||||
|
'mean_separation_nm': float,
|
||||||
|
'sog_delta_mean': float,
|
||||||
|
'cog_delta_mean': float,
|
||||||
|
'simultaneous_gap_min': float,
|
||||||
|
'g_codes': list[str],
|
||||||
|
'confidence': float,
|
||||||
|
'pair_mmsi': str,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
required_cols = {'timestamp', 'lat', 'lon', 'sog', 'cog'}
|
||||||
|
|
||||||
|
if df_a.empty or df_b.empty:
|
||||||
|
logger.debug('pair_trawl(%s, %s): empty DataFrame', mmsi_a, mmsi_b)
|
||||||
|
return _default_result(mmsi_b)
|
||||||
|
|
||||||
|
missing_a = required_cols - set(df_a.columns)
|
||||||
|
missing_b = required_cols - set(df_b.columns)
|
||||||
|
if missing_a or missing_b:
|
||||||
|
logger.warning(
|
||||||
|
'pair_trawl(%s, %s): missing columns a=%s b=%s',
|
||||||
|
mmsi_a, mmsi_b, missing_a, missing_b,
|
||||||
|
)
|
||||||
|
return _default_result(mmsi_b)
|
||||||
|
|
||||||
|
# ── Step 1: timestamp inner join ────────────────────────
|
||||||
|
a = df_a[['timestamp', 'lat', 'lon', 'sog', 'cog']].copy()
|
||||||
|
b = df_b[['timestamp', 'lat', 'lon', 'sog', 'cog']].copy()
|
||||||
|
|
||||||
|
a['timestamp'] = pd.to_datetime(a['timestamp'])
|
||||||
|
b['timestamp'] = pd.to_datetime(b['timestamp'])
|
||||||
|
|
||||||
|
merged = pd.merge(
|
||||||
|
a.rename(columns={'lat': 'lat_a', 'lon': 'lon_a', 'sog': 'sog_a', 'cog': 'cog_a'}),
|
||||||
|
b.rename(columns={'lat': 'lat_b', 'lon': 'lon_b', 'sog': 'sog_b', 'cog': 'cog_b'}),
|
||||||
|
on='timestamp',
|
||||||
|
how='inner',
|
||||||
|
).sort_values('timestamp').reset_index(drop=True)
|
||||||
|
|
||||||
|
total_aligned = len(merged)
|
||||||
|
if total_aligned < MIN_SYNC_CYCLES:
|
||||||
|
logger.debug(
|
||||||
|
'pair_trawl(%s, %s): only %d aligned rows (need %d)',
|
||||||
|
mmsi_a, mmsi_b, total_aligned, MIN_SYNC_CYCLES,
|
||||||
|
)
|
||||||
|
return _default_result(mmsi_b)
|
||||||
|
|
||||||
|
# ── Step 2: 행별 동기화 지표 계산 ───────────────────────
|
||||||
|
merged['distance_nm'] = merged.apply(
|
||||||
|
lambda r: haversine_nm(r['lat_a'], r['lon_a'], r['lat_b'], r['lon_b']),
|
||||||
|
axis=1,
|
||||||
|
)
|
||||||
|
merged['sog_delta'] = (merged['sog_a'] - merged['sog_b']).abs()
|
||||||
|
merged['cog_delta'] = merged.apply(
|
||||||
|
lambda r: _cog_delta(r['cog_a'], r['cog_b']),
|
||||||
|
axis=1,
|
||||||
|
)
|
||||||
|
merged['both_in_range'] = (
|
||||||
|
merged['sog_a'].between(SOG_MIN, SOG_MAX)
|
||||||
|
& merged['sog_b'].between(SOG_MIN, SOG_MAX)
|
||||||
|
)
|
||||||
|
merged['synced'] = (
|
||||||
|
(merged['distance_nm'] <= PROXIMITY_NM)
|
||||||
|
& (merged['sog_delta'] <= SOG_DELTA_MAX)
|
||||||
|
& (merged['cog_delta'] <= COG_DELTA_MAX)
|
||||||
|
& merged['both_in_range']
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Step 3: 연속 블록 탐지 ──────────────────────────────
|
||||||
|
max_block_cycles = _max_sync_block(merged['synced'])
|
||||||
|
if max_block_cycles < MIN_SYNC_CYCLES:
|
||||||
|
logger.debug(
|
||||||
|
'pair_trawl(%s, %s): max sync block %d cycles < %d required',
|
||||||
|
mmsi_a, mmsi_b, max_block_cycles, MIN_SYNC_CYCLES,
|
||||||
|
)
|
||||||
|
return _default_result(mmsi_b)
|
||||||
|
|
||||||
|
total_synced = int(merged['synced'].sum())
|
||||||
|
sync_duration_min = total_synced * CYCLE_INTERVAL_MIN
|
||||||
|
max_sync_block_min = max_block_cycles * CYCLE_INTERVAL_MIN
|
||||||
|
|
||||||
|
mean_separation_nm = float(merged.loc[merged['synced'], 'distance_nm'].mean())
|
||||||
|
sog_delta_mean = float(merged.loc[merged['synced'], 'sog_delta'].mean())
|
||||||
|
cog_delta_mean = float(merged.loc[merged['synced'], 'cog_delta'].mean())
|
||||||
|
|
||||||
|
# ── Step 4: 동시 AIS 차단 검출 ──────────────────────────
|
||||||
|
gaps_a = _find_gaps(df_a, gap_threshold_min=10.0)
|
||||||
|
gaps_b = _find_gaps(df_b, gap_threshold_min=10.0)
|
||||||
|
simultaneous_gap_min = _overlap_minutes(gaps_a, gaps_b)
|
||||||
|
|
||||||
|
g_codes: list[str] = []
|
||||||
|
if simultaneous_gap_min >= SIMULTANEOUS_GAP_MIN:
|
||||||
|
g_codes.append('P-01')
|
||||||
|
|
||||||
|
# ── Step 5: 신뢰도 산출 ─────────────────────────────────
|
||||||
|
sync_ratio = min(1.0, total_synced / total_aligned)
|
||||||
|
|
||||||
|
synced_distances = merged.loc[merged['synced'], 'distance_nm']
|
||||||
|
if len(synced_distances) > 1:
|
||||||
|
std_distance = float(synced_distances.std())
|
||||||
|
else:
|
||||||
|
std_distance = 0.0
|
||||||
|
separation_stability = 1.0 - min(1.0, std_distance / PROXIMITY_NM)
|
||||||
|
|
||||||
|
sog_sync_quality = 1.0 - min(1.0, sog_delta_mean / SOG_DELTA_MAX)
|
||||||
|
|
||||||
|
confidence = round(
|
||||||
|
sync_ratio * 0.4
|
||||||
|
+ separation_stability * 0.3
|
||||||
|
+ sog_sync_quality * 0.3,
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'pair_trawl(%s, %s): detected — sync=%.0fmin max_block=%.0fmin '
|
||||||
|
'sep=%.3fnm confidence=%.3f g_codes=%s',
|
||||||
|
mmsi_a, mmsi_b,
|
||||||
|
sync_duration_min, max_sync_block_min,
|
||||||
|
mean_separation_nm, confidence, g_codes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'pair_detected': True,
|
||||||
|
'sync_duration_min': round(sync_duration_min, 1),
|
||||||
|
'max_sync_block_min': round(max_sync_block_min, 1),
|
||||||
|
'mean_separation_nm': round(mean_separation_nm, 4),
|
||||||
|
'sog_delta_mean': round(sog_delta_mean, 4),
|
||||||
|
'cog_delta_mean': round(cog_delta_mean, 4),
|
||||||
|
'simultaneous_gap_min': round(simultaneous_gap_min, 1),
|
||||||
|
'g_codes': g_codes,
|
||||||
|
'confidence': confidence,
|
||||||
|
'pair_mmsi': mmsi_b,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_unregistered_pairs(
|
||||||
|
vessel_dfs: dict[str, pd.DataFrame],
|
||||||
|
registered_pairs: set[tuple[str, str]],
|
||||||
|
) -> list[tuple[str, str]]:
|
||||||
|
"""fleet_registry에 없는 TRAWL 선박 중 500m 이내 + 속력 동기화 조건을
|
||||||
|
만족하는 쌍 후보 반환. cell-key partitioning으로 O(n²) 회피.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vessel_dfs: mmsi → AIS DataFrame. 각 DataFrame은 timestamp, lat, lon, sog 컬럼 필요
|
||||||
|
registered_pairs: 이미 확인된 쌍 (fleet_tracker 제공). 정규화: (smaller, larger)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of (mmsi_a, mmsi_b) candidate pairs (정규화된 순서)
|
||||||
|
"""
|
||||||
|
if len(vessel_dfs) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
CANDIDATE_PROXIMITY_NM = PROXIMITY_NM * CANDIDATE_PROXIMITY_FACTOR
|
||||||
|
|
||||||
|
# ── 각 선박의 마지막 위치 추출 ──────────────────────────
|
||||||
|
last_positions: dict[str, dict] = {}
|
||||||
|
for mmsi, df in vessel_dfs.items():
|
||||||
|
if df.empty or 'lat' not in df.columns or 'lon' not in df.columns or 'sog' not in df.columns:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
last_row = df.sort_values('timestamp').iloc[-1]
|
||||||
|
lat = float(last_row['lat'])
|
||||||
|
lon = float(last_row['lon'])
|
||||||
|
sog = float(last_row['sog'])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
last_positions[mmsi] = {'lat': lat, 'lon': lon, 'sog': sog}
|
||||||
|
|
||||||
|
if len(last_positions) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ── cell-key 격자 구성 ───────────────────────────────────
|
||||||
|
cell_map: dict[tuple[int, int], list[str]] = {}
|
||||||
|
for mmsi, pos in last_positions.items():
|
||||||
|
key = _cell_key(pos['lat'], pos['lon'])
|
||||||
|
if key not in cell_map:
|
||||||
|
cell_map[key] = []
|
||||||
|
cell_map[key].append(mmsi)
|
||||||
|
|
||||||
|
# ── 인접 9셀 내 후보 쌍 탐색 ────────────────────────────
|
||||||
|
candidates: list[tuple[str, str]] = []
|
||||||
|
checked: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
for mmsi_a, pos_a in last_positions.items():
|
||||||
|
base_cell = _cell_key(pos_a['lat'], pos_a['lon'])
|
||||||
|
|
||||||
|
# 인접 8셀 + 자기 셀 수집
|
||||||
|
neighbor_mmsis: list[str] = []
|
||||||
|
for dr in (-1, 0, 1):
|
||||||
|
for dc in (-1, 0, 1):
|
||||||
|
neighbor_cell = (base_cell[0] + dr, base_cell[1] + dc)
|
||||||
|
neighbor_mmsis.extend(cell_map.get(neighbor_cell, []))
|
||||||
|
|
||||||
|
for mmsi_b in neighbor_mmsis:
|
||||||
|
if mmsi_b == mmsi_a:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 정규화된 쌍 키
|
||||||
|
pair_key: tuple[str, str] = (
|
||||||
|
(mmsi_a, mmsi_b) if mmsi_a < mmsi_b else (mmsi_b, mmsi_a)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 중복 검사
|
||||||
|
if pair_key in checked:
|
||||||
|
continue
|
||||||
|
checked.add(pair_key)
|
||||||
|
|
||||||
|
# 이미 등록된 쌍 건너뜀
|
||||||
|
if pair_key in registered_pairs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pos_b = last_positions.get(mmsi_b)
|
||||||
|
if pos_b is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 속력 범위 필터 (완화 기준)
|
||||||
|
sog_a = pos_a['sog']
|
||||||
|
sog_b = pos_b['sog']
|
||||||
|
if not (CANDIDATE_SOG_MIN <= sog_a <= CANDIDATE_SOG_MAX):
|
||||||
|
continue
|
||||||
|
if not (CANDIDATE_SOG_MIN <= sog_b <= CANDIDATE_SOG_MAX):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 거리 필터
|
||||||
|
dist_nm = haversine_nm(pos_a['lat'], pos_a['lon'], pos_b['lat'], pos_b['lon'])
|
||||||
|
if dist_nm > CANDIDATE_PROXIMITY_NM:
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidates.append(pair_key)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'scan_unregistered_pairs: %d vessels, %d candidates found',
|
||||||
|
len(last_positions), len(candidates),
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
@ -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 ""
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user