diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/AnalysisStatsResponse.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/AnalysisStatsResponse.java new file mode 100644 index 0000000..d9dceec --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/AnalysisStatsResponse.java @@ -0,0 +1,27 @@ +package gc.mda.kcg.domain.analysis; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +/** + * vessel_analysis_results 집계 응답. + * MMSI별 최신 row 기준으로 단일 쿼리 COUNT FILTER 집계한다. + */ +public record AnalysisStatsResponse( + long total, + long darkCount, + long spoofingCount, + long transshipCount, + long criticalCount, + long highCount, + long mediumCount, + long lowCount, + long territorialCount, + long contiguousCount, + long eezCount, + long fishingCount, + BigDecimal avgRiskScore, + OffsetDateTime windowStart, + OffsetDateTime windowEnd +) { +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/GearDetectionResponse.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearDetectionResponse.java new file mode 100644 index 0000000..bc0da6a --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearDetectionResponse.java @@ -0,0 +1,38 @@ +package gc.mda.kcg.domain.analysis; + +import java.time.OffsetDateTime; +import java.util.List; + +/** + * prediction 자동 어구 탐지 결과 응답 DTO. + * gear_code / gear_judgment 가 NOT NULL 인 row의 핵심 필드만 노출. + */ +public record GearDetectionResponse( + Long id, + String mmsi, + OffsetDateTime analyzedAt, + String vesselType, + String gearCode, + String gearJudgment, + String permitStatus, + String riskLevel, + Integer riskScore, + String zoneCode, + List violationCategories +) { + public static GearDetectionResponse from(VesselAnalysisResult e) { + return new GearDetectionResponse( + e.getId(), + e.getMmsi(), + e.getAnalyzedAt(), + e.getVesselType(), + e.getGearCode(), + e.getGearJudgment(), + e.getPermitStatus(), + e.getRiskLevel(), + e.getRiskScore(), + e.getZoneCode(), + e.getViolationCategories() + ); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java index ae0318b..71e245f 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java @@ -7,6 +7,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.web.bind.annotation.*; +import java.math.BigDecimal; import java.time.OffsetDateTime; import java.util.List; @@ -33,17 +34,52 @@ public class VesselAnalysisController { @RequestParam(required = false) String zoneCode, @RequestParam(required = false) String riskLevel, @RequestParam(required = false) Boolean isDark, + @RequestParam(required = false) String mmsiPrefix, + @RequestParam(required = false) Integer minRiskScore, + @RequestParam(required = false) BigDecimal minFishingPct, @RequestParam(defaultValue = "1") int hours, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "50") int size ) { OffsetDateTime after = OffsetDateTime.now().minusHours(hours); return service.getAnalysisResults( - mmsi, zoneCode, riskLevel, isDark, after, + mmsi, zoneCode, riskLevel, isDark, + mmsiPrefix, minRiskScore, minFishingPct, after, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt")) ).map(VesselAnalysisResponse::from); } + /** + * MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER). + * - hours: 윈도우 (기본 1시간) + * - mmsiPrefix: '412' 같은 MMSI prefix 필터 (선택) + */ + @GetMapping("/stats") + @RequirePermission(resource = "detection:dark-vessel", operation = "READ") + public AnalysisStatsResponse getStats( + @RequestParam(defaultValue = "1") int hours, + @RequestParam(required = false) String mmsiPrefix + ) { + return service.getStats(hours, mmsiPrefix); + } + + /** + * prediction 자동 어구 탐지 결과 목록. + * gear_code/gear_judgment NOT NULL 인 row만 MMSI 중복 제거 후 반환. + */ + @GetMapping("/gear-detections") + @RequirePermission(resource = "detection:dark-vessel", operation = "READ") + public Page listGearDetections( + @RequestParam(defaultValue = "1") int hours, + @RequestParam(required = false) String mmsiPrefix, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size + ) { + return service.getGearDetections(hours, mmsiPrefix, + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt")) + ).map(GearDetectionResponse::from); + } + /** * 특정 선박 최신 분석 결과 (features 포함). */ diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisRepository.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisRepository.java index 4b54e48..b6448c6 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisRepository.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param; import java.time.OffsetDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -57,4 +58,61 @@ public interface VesselAnalysisRepository """) Page findLatestTransshipSuspects( @Param("after") OffsetDateTime after, Pageable pageable); + + /** + * 어구 탐지 결과 목록 (gear_code/judgment NOT NULL, MMSI 중복 제거). + * mmsiPrefix 가 null 이면 전체, 아니면 LIKE ':prefix%'. + */ + @Query(""" + SELECT v FROM VesselAnalysisResult v + WHERE v.gearCode IS NOT NULL + AND v.gearJudgment IS NOT NULL + AND v.analyzedAt > :after + AND (:mmsiPrefix IS NULL OR v.mmsi LIKE CONCAT(:mmsiPrefix, '%')) + AND v.analyzedAt = ( + SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2 + WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after + ) + ORDER BY v.analyzedAt DESC + """) + Page findLatestGearDetections( + @Param("after") OffsetDateTime after, + @Param("mmsiPrefix") String mmsiPrefix, + Pageable pageable); + + /** + * MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER). + * mmsiPrefix 가 null 이면 전체. + * 반환 Map 키: total, dark_count, spoofing_count, transship_count, + * critical_count, high_count, medium_count, low_count, + * territorial_count, contiguous_count, eez_count, + * fishing_count, avg_risk_score + */ + @Query(value = """ + WITH latest AS ( + SELECT DISTINCT ON (mmsi) * + FROM kcg.vessel_analysis_results + WHERE analyzed_at > :after + AND (:mmsiPrefix IS NULL OR mmsi LIKE :mmsiPrefix || '%') + ORDER BY mmsi, analyzed_at DESC + ) + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE is_dark = TRUE) AS dark_count, + COUNT(*) FILTER (WHERE spoofing_score >= 0.3) AS spoofing_count, + COUNT(*) FILTER (WHERE transship_suspect = TRUE) AS transship_count, + COUNT(*) FILTER (WHERE risk_level = 'CRITICAL') AS critical_count, + COUNT(*) FILTER (WHERE risk_level = 'HIGH') AS high_count, + COUNT(*) FILTER (WHERE risk_level = 'MEDIUM') AS medium_count, + COUNT(*) FILTER (WHERE risk_level = 'LOW') AS low_count, + COUNT(*) FILTER (WHERE zone_code = 'TERRITORIAL_SEA') AS territorial_count, + COUNT(*) FILTER (WHERE zone_code = 'CONTIGUOUS_ZONE') AS contiguous_count, + COUNT(*) FILTER (WHERE zone_code = 'EEZ_OR_BEYOND') AS eez_count, + COUNT(*) FILTER (WHERE fishing_pct > 0.5) AS fishing_count, + COALESCE(AVG(risk_score), 0)::NUMERIC(5,2) AS avg_risk_score + FROM latest + """, nativeQuery = true) + Map aggregateStats( + @Param("after") OffsetDateTime after, + @Param("mmsiPrefix") String mmsiPrefix); } diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResponse.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResponse.java index c7ec691..ce81ec7 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResponse.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResponse.java @@ -2,6 +2,7 @@ package gc.mda.kcg.domain.analysis; import java.math.BigDecimal; import java.time.OffsetDateTime; +import java.util.List; import java.util.Map; /** @@ -16,6 +17,7 @@ public record VesselAnalysisResponse( String vesselType, BigDecimal confidence, BigDecimal fishingPct, + Integer clusterId, String season, // 위치 Double lat, @@ -24,11 +26,14 @@ public record VesselAnalysisResponse( BigDecimal distToBaselineNm, // 행동 String activityState, + BigDecimal ucafScore, + BigDecimal ucftScore, // 위협 Boolean isDark, Integer gapDurationMin, String darkPattern, BigDecimal spoofingScore, + BigDecimal bd09OffsetM, Integer speedJumpCount, // 환적 Boolean transshipSuspect, @@ -45,6 +50,7 @@ public record VesselAnalysisResponse( String gearCode, String gearJudgment, String permitStatus, + List violationCategories, // features Map features ) { @@ -56,16 +62,20 @@ public record VesselAnalysisResponse( e.getVesselType(), e.getConfidence(), e.getFishingPct(), + e.getClusterId(), e.getSeason(), e.getLat(), e.getLon(), e.getZoneCode(), e.getDistToBaselineNm(), e.getActivityState(), + e.getUcafScore(), + e.getUcftScore(), e.getIsDark(), e.getGapDurationMin(), e.getDarkPattern(), e.getSpoofingScore(), + e.getBd09OffsetM(), e.getSpeedJumpCount(), e.getTransshipSuspect(), e.getTransshipPairMmsi(), @@ -78,6 +88,7 @@ public record VesselAnalysisResponse( e.getGearCode(), e.getGearJudgment(), e.getPermitStatus(), + e.getViolationCategories(), e.getFeatures() ); } diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java index cedc132..c9db0b8 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java @@ -7,6 +7,7 @@ import org.hibernate.type.SqlTypes; import java.math.BigDecimal; import java.time.OffsetDateTime; +import java.util.List; import java.util.Map; /** @@ -125,6 +126,10 @@ public class VesselAnalysisResult { @Column(name = "permit_status", length = 20) private String permitStatus; + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "violation_categories", columnDefinition = "text[]") + private List violationCategories; + // features JSONB @JdbcTypeCode(SqlTypes.JSON) @Column(name = "features", columnDefinition = "jsonb") diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java index 79a6555..a02f264 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java @@ -7,8 +7,10 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; import java.time.OffsetDateTime; import java.util.List; +import java.util.Map; /** * vessel_analysis_results 직접 조회 서비스. @@ -26,9 +28,10 @@ public class VesselAnalysisService { */ public Page getAnalysisResults( String mmsi, String zoneCode, String riskLevel, Boolean isDark, + String mmsiPrefix, Integer minRiskScore, BigDecimal minFishingPct, OffsetDateTime after, Pageable pageable ) { - Specification spec = Specification.where(null); + Specification spec = (root, query, cb) -> cb.conjunction(); if (after != null) { spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after)); @@ -36,6 +39,10 @@ public class VesselAnalysisService { if (mmsi != null && !mmsi.isBlank()) { spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi)); } + if (mmsiPrefix != null && !mmsiPrefix.isBlank()) { + final String prefix = mmsiPrefix; + spec = spec.and((root, query, cb) -> cb.like(root.get("mmsi"), prefix + "%")); + } if (zoneCode != null && !zoneCode.isBlank()) { spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode)); } @@ -45,10 +52,66 @@ public class VesselAnalysisService { if (isDark != null && isDark) { spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark"))); } + if (minRiskScore != null) { + spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get("riskScore"), minRiskScore)); + } + if (minFishingPct != null) { + spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("fishingPct"), minFishingPct)); + } return repository.findAll(spec, pageable); } + /** + * MMSI별 최신 row 기준 집계 (단일 쿼리). + */ + public AnalysisStatsResponse getStats(int hours, String mmsiPrefix) { + OffsetDateTime windowEnd = OffsetDateTime.now(); + OffsetDateTime windowStart = windowEnd.minusHours(hours); + String prefix = (mmsiPrefix != null && !mmsiPrefix.isBlank()) ? mmsiPrefix : null; + + Map row = repository.aggregateStats(windowStart, prefix); + return new AnalysisStatsResponse( + longOf(row, "total"), + longOf(row, "dark_count"), + longOf(row, "spoofing_count"), + longOf(row, "transship_count"), + longOf(row, "critical_count"), + longOf(row, "high_count"), + longOf(row, "medium_count"), + longOf(row, "low_count"), + longOf(row, "territorial_count"), + longOf(row, "contiguous_count"), + longOf(row, "eez_count"), + longOf(row, "fishing_count"), + bigDecimalOf(row, "avg_risk_score"), + windowStart, + windowEnd + ); + } + + /** + * prediction 자동 어구 탐지 결과 목록. + */ + public Page getGearDetections(int hours, String mmsiPrefix, Pageable pageable) { + OffsetDateTime after = OffsetDateTime.now().minusHours(hours); + String prefix = (mmsiPrefix != null && !mmsiPrefix.isBlank()) ? mmsiPrefix : null; + return repository.findLatestGearDetections(after, prefix, pageable); + } + + private static long longOf(Map row, String key) { + Object v = row.get(key); + if (v == null) return 0L; + return ((Number) v).longValue(); + } + + private static BigDecimal bigDecimalOf(Map row, String key) { + Object v = row.get(key); + if (v == null) return BigDecimal.ZERO; + if (v instanceof BigDecimal bd) return bd; + return new BigDecimal(v.toString()); + } + /** * 특정 선박 최신 분석 결과. */ diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index de9eafb..6233ca2 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,15 @@ ## [Unreleased] +### 추가 +- **중국어선 감시 화면 실데이터 연동 (3개 탭)** — deprecated iran proxy `/api/vessel-analysis` → 자체 백엔드 `/api/analysis/*` 전환. AI 감시 대시보드·환적접촉탐지·어구/어망 판별 모두 prediction 5분 사이클 결과 실시간 반영. 관심영역/VIIRS/기상/VTS 카드는 "데모 데이터" 뱃지, 비허가/제재/관심 선박 탭은 "준비중" 뱃지로 데이터 소스 미연동 항목 명시 +- **특이운항 미니맵 + 판별 구간 패널** — AI 감시 대시보드 선박 리스트 클릭 → 24h AIS 항적(MapLibre + deck.gl) + Dark/Spoofing/환적/어구위반/고위험 신호를 시간순 segment 로 병합해 지도 하이라이트(CRITICAL/WARNING/INFO 3단계). 판별 패널에 시작~종료·지속·N회 연속 감지·카테고리·설명 표시. 어구/어망 판별 탭 최하단 자동탐지 결과 row 클릭 시 상단 입력 폼 프리필 +- **`/api/analysis/stats`** — MMSI별 최신 row 기준 단일 쿼리 COUNT FILTER 집계(total/dark/spoofing/transship/risk 분포/zone 분포/fishing/avgRiskScore + windowStart/End). 선택적 `mmsiPrefix` 필터(중국 선박 '412' 등) +- **`/api/analysis/gear-detections`** — gear_code/judgment NOT NULL row MMSI 중복 제거 목록. 자동 탐지 결과 섹션 연동용 +- **`/api/analysis/vessels` 필터 확장** — `mmsiPrefix` / `minRiskScore` / `minFishingPct` 쿼리 파라미터 추가 +- **VesselAnalysisResponse 필드 확장** — `violationCategories` / `bd09OffsetM` / `ucafScore` / `ucftScore` / `clusterId` 5개 필드 노출 +- **prediction 분석 시점 좌표 저장** — `AnalysisResult` + `to_db_tuple` + `upsert_results` SQL 에 `lat/lon` 추가. 분류 파이프라인(last_row) / 경량 분석(all_positions) 두 경로 주입. 기존 `vessel_analysis_results.lat/lon` 컬럼이 항상 NULL 이던 구조적 누락 해결 (첫 사이클 8173/8173 non-null 확인) + ## [2026-04-16.5] ### 변경 diff --git a/frontend/src/features/detection/ChinaFishing.tsx b/frontend/src/features/detection/ChinaFishing.tsx index 2ea7e8f..aacb328 100644 --- a/frontend/src/features/detection/ChinaFishing.tsx +++ b/frontend/src/features/detection/ChinaFishing.tsx @@ -3,9 +3,9 @@ import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { PageContainer } from '@shared/components/layout'; import { - Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud, + Search, Clock, ChevronRight, ChevronLeft, Cloud, Eye, AlertTriangle, Radio, RotateCcw, - MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2 + Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2 } from 'lucide-react'; import { formatDateTime } from '@shared/utils/dateFormat'; import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels'; @@ -14,22 +14,23 @@ import { useSettingsStore } from '@stores/settingsStore'; import { useTranslation } from 'react-i18next'; import { GearIdentification } from './GearIdentification'; import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis'; +import { VesselMiniMap } from './components/VesselMiniMap'; +import { VesselAnomalyPanel } from './components/VesselAnomalyPanel'; +import { extractAnomalies, groupAnomaliesToSegments } from './components/vesselAnomaly'; import { PieChart as EcPieChart } from '@lib/charts'; +import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi'; import { - fetchVesselAnalysis, - filterDarkVessels, - filterTransshipSuspects, - type VesselAnalysisItem, - type VesselAnalysisStats, -} from '@/services/vesselAnalysisApi'; + getAnalysisStats, + getAnalysisVessels, + getAnalysisHistory, + type AnalysisStats, + type VesselAnalysis, +} from '@/services/analysisApi'; +import { toVesselItem } from '@/services/analysisAdapter'; // ─── 중국 MMSI prefix ───────────── const CHINA_MMSI_PREFIX = '412'; -function isChinaVessel(mmsi: string): boolean { - return mmsi.startsWith(CHINA_MMSI_PREFIX); -} - // ─── 특이운항 선박 리스트 타입 ──────────────── type VesselStatus = '의심' | '양호' | '경고'; interface VesselItem { @@ -53,14 +54,16 @@ function deriveVesselStatus(score: number): VesselStatus { function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem { const score = item.algorithms.riskScore.score; + const vt = item.classification.vesselType; + const hasType = vt && vt !== 'UNKNOWN' && vt !== ''; return { id: String(idx + 1), mmsi: item.mmsi, callSign: '-', channel: '', source: 'AIS', - name: item.classification.vesselType || item.mmsi, - type: item.classification.fishingPct > 0.5 ? 'Fishing' : 'Cargo', + name: hasType ? vt : '중국어선', + type: item.classification.fishingPct > 0.5 ? 'Fishing' : hasType ? 'Cargo' : '미분류', country: 'China', status: deriveVesselStatus(score), riskPct: score, @@ -202,10 +205,14 @@ export function ChinaFishing() { const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard'); const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항'); const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계'); + const [selectedMmsi, setSelectedMmsi] = useState(null); + const [history, setHistory] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(''); // API state - const [allItems, setAllItems] = useState([]); - const [apiStats, setApiStats] = useState(null); + const [topVessels, setTopVessels] = useState([]); + const [apiStats, setApiStats] = useState(null); const [serviceAvailable, setServiceAvailable] = useState(true); const [apiLoading, setApiLoading] = useState(false); const [apiError, setApiError] = useState(''); @@ -214,10 +221,18 @@ export function ChinaFishing() { setApiLoading(true); setApiError(''); try { - const res = await fetchVesselAnalysis(); - setServiceAvailable(res.serviceAvailable); - setAllItems(res.items); - setApiStats(res.stats); + const [stats, topPage] = await Promise.all([ + getAnalysisStats({ hours: 1, mmsiPrefix: CHINA_MMSI_PREFIX }), + getAnalysisVessels({ + hours: 1, + mmsiPrefix: CHINA_MMSI_PREFIX, + minRiskScore: 40, + size: 20, + }), + ]); + setApiStats(stats); + setTopVessels(topPage.content.map(toVesselItem)); + setServiceAvailable(true); } catch (e: unknown) { setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다'); setServiceAvailable(false); @@ -228,55 +243,77 @@ export function ChinaFishing() { useEffect(() => { loadApi(); }, [loadApi]); - // 중국어선 필터 - const chinaVessels = useMemo( - () => allItems.filter((i) => isChinaVessel(i.mmsi)), - [allItems], + // 선박 선택 시 24h 분석 이력 로드 (미니맵 anomaly 포인트 + 판별 패널 공통 데이터) + useEffect(() => { + if (!selectedMmsi) { setHistory([]); setHistoryError(''); return; } + let cancelled = false; + setHistoryLoading(true); setHistoryError(''); + getAnalysisHistory(selectedMmsi, 24) + .then((rows) => { if (!cancelled) setHistory(rows); }) + .catch((e: unknown) => { + if (cancelled) return; + setHistory([]); + setHistoryError(e instanceof Error ? e.message : '이력 조회 실패'); + }) + .finally(() => { if (!cancelled) setHistoryLoading(false); }); + return () => { cancelled = true; }; + }, [selectedMmsi]); + + const anomalySegments = useMemo( + () => groupAnomaliesToSegments(extractAnomalies(history)), + [history], ); - const chinaDark = useMemo(() => filterDarkVessels(chinaVessels), [chinaVessels]); - const chinaTransship = useMemo(() => filterTransshipSuspects(chinaVessels), [chinaVessels]); + // ─ 파생 계산 (서버 집계 우선) ─ + // Tab 1 '분석 대상' 및 카운터는 apiStats 값이 SSOT. + // topVessels 는 minRiskScore=40 으로 필터된 상위 20척 (특이운항 리스트 전용). + const countersRow1 = useMemo(() => { + if (!apiStats) return []; + return [ + { label: '통합', count: apiStats.total, color: '#6b7280' }, + { label: 'AIS', count: apiStats.total, color: '#3b82f6' }, + { label: 'EEZ 내', count: apiStats.territorialCount + apiStats.contiguousCount, color: '#8b5cf6' }, + { label: '어업선', count: apiStats.fishingCount, color: '#10b981' }, + ]; + }, [apiStats]); - // 센서 카운터 (API 기반) - const countersRow1 = useMemo(() => [ - { label: '통합', count: allItems.length, color: '#6b7280' }, - { label: 'AIS', count: allItems.length, color: '#3b82f6' }, - { label: 'EEZ 내', count: allItems.filter((i) => i.algorithms.location.zone !== 'EEZ_OR_BEYOND').length, color: '#8b5cf6' }, - { label: '어업선', count: allItems.filter((i) => i.classification.fishingPct > 0.5).length, color: '#10b981' }, - ], [allItems]); + const countersRow2 = useMemo(() => { + if (!apiStats) return []; + return [ + { label: '중국어선', count: apiStats.total, color: '#f97316' }, + { label: 'Dark Vessel', count: apiStats.darkCount, color: '#ef4444' }, + { label: '환적 의심', count: apiStats.transshipCount, color: '#06b6d4' }, + { label: '고위험', count: apiStats.criticalCount + apiStats.highCount, color: '#ef4444' }, + ]; + }, [apiStats]); - const countersRow2 = useMemo(() => [ - { label: '중국어선', count: chinaVessels.length, color: '#f97316' }, - { label: 'Dark Vessel', count: chinaDark.length, color: '#ef4444' }, - { label: '환적 의심', count: chinaTransship.length, color: '#06b6d4' }, - { label: '고위험', count: chinaVessels.filter((i) => i.algorithms.riskScore.score >= 70).length, color: '#ef4444' }, - ], [chinaVessels, chinaDark, chinaTransship]); - - // 특이운항 선박 리스트 (중국어선 중 riskScore >= 40) + // 특이운항 선박 리스트 (서버에서 이미 riskScore >= 40 로 필터링된 상위 20척) const vesselList: VesselItem[] = useMemo( - () => chinaVessels - .filter((i) => i.algorithms.riskScore.score >= 40) - .sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score) - .slice(0, 20) - .map((item, idx) => mapToVesselItem(item, idx)), - [chinaVessels], + () => topVessels.map((item, idx) => mapToVesselItem(item, idx)), + [topVessels], ); - // 위험도별 분포 (도넛 차트용) + // 위험도별 분포 (도넛 차트용) — apiStats 기반 const riskDistribution = useMemo(() => { - const critical = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length; - const high = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'HIGH').length; - const medium = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length; - const low = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'LOW').length; - return { critical, high, medium, low, total: chinaVessels.length }; - }, [chinaVessels]); + if (!apiStats) return { critical: 0, high: 0, medium: 0, low: 0, total: 0 }; + return { + critical: apiStats.criticalCount, + high: apiStats.highCount, + medium: apiStats.mediumCount, + low: apiStats.lowCount, + total: apiStats.total, + }; + }, [apiStats]); - // 안전도 지수 계산 + // 안전도 지수 계산 (avgRiskScore 0~100 → 0~10 스케일) const safetyIndex = useMemo(() => { - if (chinaVessels.length === 0) return { risk: 0, safety: 100 }; - const avgRisk = chinaVessels.reduce((s, i) => s + i.algorithms.riskScore.score, 0) / chinaVessels.length; - return { risk: Number((avgRisk / 10).toFixed(2)), safety: Number(((100 - avgRisk) / 10).toFixed(2)) }; - }, [chinaVessels]); + const avgRisk = apiStats ? Number(apiStats.avgRiskScore) : 0; + if (!apiStats || apiStats.total === 0) return { risk: 0, safety: 100 }; + return { + risk: Number((avgRisk / 10).toFixed(2)), + safety: Number(((100 - avgRisk) / 10).toFixed(2)), + }; + }, [apiStats]); const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const; const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const; @@ -319,7 +356,7 @@ export function ChinaFishing() { {!serviceAvailable && (
- iran 분석 서비스 미연결 - 실시간 데이터를 불러올 수 없습니다 + 분석 API 호출 실패 - 잠시 후 다시 시도해주세요
)} @@ -371,7 +408,7 @@ export function ChinaFishing() {
해역 전체 통항량 - {allItems.length.toLocaleString()} + {(apiStats?.total ?? 0).toLocaleString()} (척)
@@ -422,12 +459,15 @@ export function ChinaFishing() { - {/* 관심영역 안전도 */} + {/* 관심영역 안전도 (해역 지오펜스 미구축 → 데모) */}
- 관심영역 안전도 +
+ 관심영역 안전도 + 데모 데이터 +