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 new file mode 100644 index 0000000..b6fd0a7 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java @@ -0,0 +1,35 @@ +package gc.mda.kcg.domain.analysis; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/vessel-analysis") +@RequiredArgsConstructor +public class VesselAnalysisController { + + private final VesselAnalysisService vesselAnalysisService; + + /** + * 최근 선박 분석 결과 조회 + * @param region 지역 필터 (향후 확장용, 현재 미사용) + */ + @GetMapping + public ResponseEntity> getVesselAnalysis( + @RequestParam(required = false) String region) { + + List results = vesselAnalysisService.getLatestResults(); + + return ResponseEntity.ok(Map.of( + "count", results.size(), + "items", results + )); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java new file mode 100644 index 0000000..a8d2e52 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java @@ -0,0 +1,148 @@ +package gc.mda.kcg.domain.analysis; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class VesselAnalysisDto { + + private String mmsi; + private String timestamp; + private Classification classification; + private Algorithms algorithms; + private Map features; + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Classification { + private String vesselType; + private Double confidence; + private Double fishingPct; + private Integer clusterId; + private String season; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Algorithms { + private LocationInfo location; + private ActivityInfo activity; + private DarkVesselInfo darkVessel; + private GpsSpoofingInfo gpsSpoofing; + private ClusterInfo cluster; + private FleetRoleInfo fleetRole; + private RiskScoreInfo riskScore; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class LocationInfo { + private String zone; + private Double distToBaselineNm; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ActivityInfo { + private String state; + private Double ucafScore; + private Double ucftScore; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class DarkVesselInfo { + private Boolean isDark; + private Integer gapDurationMin; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class GpsSpoofingInfo { + private Double spoofingScore; + private Double bd09OffsetM; + private Integer speedJumpCount; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ClusterInfo { + private Integer clusterId; + private Integer clusterSize; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class FleetRoleInfo { + private Boolean isLeader; + private String role; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class RiskScoreInfo { + private Integer score; + private String level; + } + + public static VesselAnalysisDto from(VesselAnalysisResult r) { + return VesselAnalysisDto.builder() + .mmsi(r.getMmsi()) + .timestamp(r.getTimestamp().toString()) + .classification(Classification.builder() + .vesselType(r.getVesselType()) + .confidence(r.getConfidence()) + .fishingPct(r.getFishingPct()) + .clusterId(r.getClusterId()) + .season(r.getSeason()) + .build()) + .algorithms(Algorithms.builder() + .location(LocationInfo.builder() + .zone(r.getZone()) + .distToBaselineNm(r.getDistToBaselineNm()) + .build()) + .activity(ActivityInfo.builder() + .state(r.getActivityState()) + .ucafScore(r.getUcafScore()) + .ucftScore(r.getUcftScore()) + .build()) + .darkVessel(DarkVesselInfo.builder() + .isDark(r.getIsDark()) + .gapDurationMin(r.getGapDurationMin()) + .build()) + .gpsSpoofing(GpsSpoofingInfo.builder() + .spoofingScore(r.getSpoofingScore()) + .bd09OffsetM(r.getBd09OffsetM()) + .speedJumpCount(r.getSpeedJumpCount()) + .build()) + .cluster(ClusterInfo.builder() + .clusterId(r.getClusterId()) + .clusterSize(r.getClusterSize()) + .build()) + .fleetRole(FleetRoleInfo.builder() + .isLeader(r.getIsLeader()) + .role(r.getFleetRole()) + .build()) + .riskScore(RiskScoreInfo.builder() + .score(r.getRiskScore()) + .level(r.getRiskLevel()) + .build()) + .build()) + .features(r.getFeatures()) + .build(); + } +} 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 new file mode 100644 index 0000000..6306d8f --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java @@ -0,0 +1,97 @@ +package gc.mda.kcg.domain.analysis; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Map; + +@Entity +@Table(name = "vessel_analysis_results", schema = "kcg") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VesselAnalysisResult { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 15) + private String mmsi; + + @Column(nullable = false) + private Instant timestamp; + + @Column(length = 20) + private String vesselType; + + private Double confidence; + + private Double fishingPct; + + private Integer clusterId; + + @Column(length = 10) + private String season; + + @Column(length = 20) + private String zone; + + private Double distToBaselineNm; + + @Column(length = 20) + private String activityState; + + private Double ucafScore; + + private Double ucftScore; + + @Column(nullable = false) + private Boolean isDark; + + private Integer gapDurationMin; + + private Double spoofingScore; + + private Double bd09OffsetM; + + private Integer speedJumpCount; + + private Integer clusterSize; + + @Column(nullable = false) + private Boolean isLeader; + + @Column(length = 20) + private String fleetRole; + + private Integer riskScore; + + @Column(length = 20) + private String riskLevel; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private Map features; + + @Column(nullable = false) + private Instant analyzedAt; + + @PrePersist + protected void onCreate() { + if (analyzedAt == null) { + analyzedAt = Instant.now(); + } + if (isDark == null) { + isDark = false; + } + if (isLeader == null) { + isLeader = false; + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java new file mode 100644 index 0000000..17f117b --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.domain.analysis; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.List; + +public interface VesselAnalysisResultRepository extends JpaRepository { + + List findByTimestampAfter(Instant since); +} 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 new file mode 100644 index 0000000..9e78162 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java @@ -0,0 +1,47 @@ +package gc.mda.kcg.domain.analysis; + +import gc.mda.kcg.config.CacheConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class VesselAnalysisService { + + private static final int RECENT_MINUTES = 10; + + private final VesselAnalysisResultRepository repository; + private final CacheManager cacheManager; + + /** + * 최근 10분 내 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용. + */ + @SuppressWarnings("unchecked") + public List getLatestResults() { + Cache cache = cacheManager.getCache(CacheConfig.VESSEL_ANALYSIS); + if (cache != null) { + Cache.ValueWrapper wrapper = cache.get("data"); + if (wrapper != null) { + return (List) wrapper.get(); + } + } + + Instant since = Instant.now().minus(RECENT_MINUTES, ChronoUnit.MINUTES); + List results = repository.findByTimestampAfter(since) + .stream() + .map(VesselAnalysisDto::from) + .toList(); + + if (cache != null) { + cache.put("data", results); + } + + return results; + } +} diff --git a/database/migration/005_vessel_analysis.sql b/database/migration/005_vessel_analysis.sql new file mode 100644 index 0000000..82650f3 --- /dev/null +++ b/database/migration/005_vessel_analysis.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS kcg.vessel_analysis_results ( + id BIGSERIAL PRIMARY KEY, + mmsi VARCHAR(15) NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + vessel_type VARCHAR(20), + confidence DOUBLE PRECISION, + fishing_pct DOUBLE PRECISION, + cluster_id INTEGER, + season VARCHAR(10), + zone VARCHAR(20), + dist_to_baseline_nm DOUBLE PRECISION, + activity_state VARCHAR(20), + ucaf_score DOUBLE PRECISION, + ucft_score DOUBLE PRECISION, + is_dark BOOLEAN DEFAULT FALSE, + gap_duration_min INTEGER, + spoofing_score DOUBLE PRECISION, + bd09_offset_m DOUBLE PRECISION, + speed_jump_count INTEGER, + cluster_size INTEGER, + is_leader BOOLEAN DEFAULT FALSE, + fleet_role VARCHAR(20), + risk_score INTEGER, + risk_level VARCHAR(20), + features JSONB, + analyzed_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_vessel_analysis_mmsi ON kcg.vessel_analysis_results(mmsi); +CREATE INDEX IF NOT EXISTS idx_vessel_analysis_timestamp ON kcg.vessel_analysis_results(timestamp DESC); diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index d2b34c5..bd98d20 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,15 @@ ## [Unreleased] +### 추가 +- Python 분석 결과 오버레이: 위험도 마커(CRITICAL/HIGH/MEDIUM) + 다크베셀/GPS 스푸핑 경고 +- AI 분석 통계 패널 (우상단, 접이식): 분석 대상/위험/다크/선단 집계 +- 불법어선 필터에 Python risk_level(CRITICAL/HIGH) 선박 자동 포함 +- 다크베셀 필터에 Python is_dark 감지 결과 합집합 +- 중국어선감시에 Python fleet_role(LEADER/MEMBER) 역할 우선 표시 +- Backend vessel-analysis REST API 복원 (JPA + Caffeine 캐시) +- DB vessel_analysis_results 테이블 생성 (005 마이그레이션) + ## [2026-03-20] ### 추가 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f004236..e73a25f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import { useMonitor } from './hooks/useMonitor'; import { useIranData } from './hooks/useIranData'; import { useKoreaData } from './hooks/useKoreaData'; import { useKoreaFilters } from './hooks/useKoreaFilters'; +import { useVesselAnalysis } from './hooks/useVesselAnalysis'; import type { GeoEvent, LayerVisibility, AppMode } from './types'; import { useTheme } from './hooks/useTheme'; import { useAuth } from './hooks/useAuth'; @@ -186,11 +187,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { refreshKey, }); + // Vessel analysis (Python prediction 결과) + const vesselAnalysis = useVesselAnalysis(dashboardTab === 'korea'); + // Korea filters hook const koreaFiltersResult = useKoreaFilters( koreaData.ships, koreaData.visibleShips, currentTime, + vesselAnalysis.analysisMap, ); const toggleLayer = useCallback((key: keyof LayerVisibility) => { @@ -560,6 +565,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { cableWatchSuspects={koreaFiltersResult.cableWatchSuspects} dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects} dokdoAlerts={koreaFiltersResult.dokdoAlerts} + vesselAnalysis={vesselAnalysis} />
= { + CRITICAL: '#ef4444', + HIGH: '#f97316', + MEDIUM: '#eab308', + LOW: '#22c55e', +}; + +const RISK_PRIORITY: Record = { + CRITICAL: 0, + HIGH: 1, + MEDIUM: 2, + LOW: 3, +}; + +interface Props { + ships: Ship[]; + analysisMap: Map; + clusters: Map; + activeFilter: string | null; +} + +interface AnalyzedShip { + ship: Ship; + dto: VesselAnalysisDto; +} + +/** 위험도 펄스 애니메이션 인라인 스타일 */ +function riskPulseStyle(riskLevel: string): React.CSSProperties { + const color = RISK_COLORS[riskLevel] ?? RISK_COLORS['LOW']; + return { + width: 10, + height: 10, + borderRadius: '50%', + backgroundColor: color, + boxShadow: `0 0 6px 2px ${color}88`, + animation: riskLevel === 'CRITICAL' ? 'pulse 1s infinite' : undefined, + pointerEvents: 'none', + }; +} + +export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }: Props) { + // analysisMap에 있는 선박만 대상 + const analyzedShips: AnalyzedShip[] = useMemo(() => { + return ships + .filter(s => analysisMap.has(s.mmsi)) + .map(s => ({ ship: s, dto: analysisMap.get(s.mmsi)! })); + }, [ships, analysisMap]); + + // 위험도 마커 — CRITICAL/HIGH 우선 최대 100개 + const riskMarkers = useMemo(() => { + return analyzedShips + .filter(({ dto }) => { + const level = dto.algorithms.riskScore.level; + return level === 'CRITICAL' || level === 'HIGH' || level === 'MEDIUM'; + }) + .sort((a, b) => { + const pa = RISK_PRIORITY[a.dto.algorithms.riskScore.level] ?? 99; + const pb = RISK_PRIORITY[b.dto.algorithms.riskScore.level] ?? 99; + return pa - pb; + }) + .slice(0, 100); + }, [analyzedShips]); + + // 다크베셀 마커 + const darkVesselMarkers = useMemo(() => { + if (activeFilter !== 'darkVessel') return []; + return analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark); + }, [analyzedShips, activeFilter]); + + // GPS 스푸핑 마커 + const spoofingMarkers = useMemo(() => { + return analyzedShips.filter(({ dto }) => dto.algorithms.gpsSpoofing.spoofingScore > 0.5); + }, [analyzedShips]); + + // 선단 연결선 GeoJSON (cnFishing 필터 ON일 때) + const clusterLineGeoJson = useMemo(() => { + if (activeFilter !== 'cnFishing') { + return { type: 'FeatureCollection' as const, features: [] }; + } + + const features: GeoJSON.Feature[] = []; + + for (const [clusterId, mmsiList] of clusters) { + if (mmsiList.length < 2) continue; + + // cluster 내 선박 위치 조회 + const clusterShips = mmsiList + .map(mmsi => { + const ship = ships.find(s => s.mmsi === mmsi); + return ship ?? null; + }) + .filter((s): s is Ship => s !== null); + + if (clusterShips.length < 2) continue; + + // leader 찾기 + const leaderMmsi = mmsiList.find(mmsi => { + const dto = analysisMap.get(mmsi); + return dto?.algorithms.fleetRole.isLeader === true; + }); + + // leader → 각 member 연결선 + if (leaderMmsi) { + const leaderShip = ships.find(s => s.mmsi === leaderMmsi); + if (leaderShip) { + for (const memberShip of clusterShips) { + if (memberShip.mmsi === leaderMmsi) continue; + features.push({ + type: 'Feature' as const, + properties: { clusterId, leaderMmsi, memberMmsi: memberShip.mmsi }, + geometry: { + type: 'LineString' as const, + coordinates: [ + [leaderShip.lng, leaderShip.lat], + [memberShip.lng, memberShip.lat], + ], + }, + }); + } + } + } else { + // leader 없으면 순차 연결 + for (let i = 0; i < clusterShips.length - 1; i++) { + features.push({ + type: 'Feature' as const, + properties: { clusterId, leaderMmsi: null, memberMmsi: clusterShips[i + 1].mmsi }, + geometry: { + type: 'LineString' as const, + coordinates: [ + [clusterShips[i].lng, clusterShips[i].lat], + [clusterShips[i + 1].lng, clusterShips[i + 1].lat], + ], + }, + }); + } + } + } + + return { type: 'FeatureCollection' as const, features }; + }, [activeFilter, clusters, ships, analysisMap]); + + // leader 선박 목록 (cnFishing 필터 ON) + const leaderShips = useMemo(() => { + if (activeFilter !== 'cnFishing') return []; + return analyzedShips.filter(({ dto }) => dto.algorithms.fleetRole.isLeader); + }, [analyzedShips, activeFilter]); + + return ( + <> + {/* 선단 연결선 */} + {clusterLineGeoJson.features.length > 0 && ( + + + + )} + + {/* 위험도 마커 */} + {riskMarkers.map(({ ship, dto }) => { + const level = dto.algorithms.riskScore.level; + const color = RISK_COLORS[level] ?? RISK_COLORS['LOW']; + return ( + +
+ {/* 삼각형 아이콘 */} +
+ {/* 위험도 텍스트 */} +
+ {level} +
+
+ + ); + })} + + {/* CRITICAL 펄스 오버레이 */} + {riskMarkers + .filter(({ dto }) => dto.algorithms.riskScore.level === 'CRITICAL') + .map(({ ship }) => ( + +
+ + ))} + + {/* 다크베셀 마커 */} + {darkVesselMarkers.map(({ ship, dto }) => { + const gapMin = dto.algorithms.darkVessel.gapDurationMin; + return ( + +
+ {/* 보라 점선 원 */} +
+ {/* gap 라벨 */} +
+ {gapMin > 0 ? `${Math.round(gapMin)}분` : 'DARK'} +
+
+ + ); + })} + + {/* GPS 스푸핑 배지 */} + {spoofingMarkers.map(({ ship }) => ( + +
+ GPS +
+
+ ))} + + {/* 선단 leader 별 아이콘 */} + {leaderShips.map(({ ship }) => ( + +
+ ★ +
+
+ ))} + + ); +} diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx new file mode 100644 index 0000000..016d6b1 --- /dev/null +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -0,0 +1,175 @@ +import { useState, useMemo } from 'react'; +import type { AnalysisStats } from '../../hooks/useVesselAnalysis'; + +interface Props { + stats: AnalysisStats; + lastUpdated: number; + isLoading: boolean; +} + +/** unix ms → HH:MM 형식 */ +function formatTime(ms: number): string { + if (ms === 0) return '--:--'; + const d = new Date(ms); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + return `${hh}:${mm}`; +} + +export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) { + const [expanded, setExpanded] = useState(true); + + const isEmpty = useMemo(() => stats.total === 0, [stats.total]); + + const panelStyle: React.CSSProperties = { + position: 'absolute', + top: 60, + right: 10, + zIndex: 10, + minWidth: 160, + backgroundColor: 'rgba(12, 24, 37, 0.92)', + border: '1px solid rgba(99, 179, 237, 0.25)', + borderRadius: 8, + color: '#e2e8f0', + fontFamily: 'monospace, sans-serif', + fontSize: 11, + boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)', + overflow: 'hidden', + }; + + const headerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '6px 10px', + borderBottom: expanded ? '1px solid rgba(99, 179, 237, 0.15)' : 'none', + cursor: 'default', + userSelect: 'none', + }; + + const toggleButtonStyle: React.CSSProperties = { + background: 'none', + border: 'none', + color: '#94a3b8', + cursor: 'pointer', + fontSize: 10, + padding: '0 2px', + lineHeight: 1, + }; + + const bodyStyle: React.CSSProperties = { + padding: '8px 10px', + }; + + const rowStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 3, + }; + + const labelStyle: React.CSSProperties = { + color: '#94a3b8', + }; + + const valueStyle: React.CSSProperties = { + fontWeight: 700, + color: '#e2e8f0', + }; + + const dividerStyle: React.CSSProperties = { + borderTop: '1px solid rgba(99, 179, 237, 0.15)', + margin: '6px 0', + }; + + const riskRowStyle: React.CSSProperties = { + display: 'flex', + gap: 6, + justifyContent: 'space-between', + marginTop: 4, + }; + + const riskBadgeStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 2, + color: '#cbd5e1', + fontSize: 10, + }; + + return ( +
+ {/* 헤더 */} +
+
+ AI 분석 + {isLoading && ( + 로딩중... + )} +
+
+ {formatTime(lastUpdated)} + +
+
+ + {/* 본문 */} + {expanded && ( +
+ {isEmpty ? ( +
+ 분석 데이터 없음 +
+ ) : ( + <> +
+ 전체 + {stats.total} +
+
+ 다크베셀 + {stats.dark} +
+
+ GPS스푸핑 + {stats.spoofing} +
+
+ 선단수 + {stats.clusterCount} +
+ +
+ + {/* 위험도 수치 행 */} +
+
+ 🔴 + {stats.critical} +
+
+ 🟠 + {stats.high} +
+
+ 🟡 + {stats.medium} +
+
+ 🟢 + {stats.low} +
+
+ + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/korea/ChineseFishingOverlay.tsx b/frontend/src/components/korea/ChineseFishingOverlay.tsx index 64eb937..7579f8e 100644 --- a/frontend/src/components/korea/ChineseFishingOverlay.tsx +++ b/frontend/src/components/korea/ChineseFishingOverlay.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { Marker, Source, Layer } from 'react-map-gl/maplibre'; -import type { Ship } from '../../types'; +import type { Ship, VesselAnalysisDto } from '../../types'; import { analyzeFishing, GEAR_LABELS } from '../../utils/fishingAnalysis'; import type { FishingGearType } from '../../utils/fishingAnalysis'; import { getMarineTrafficCategory } from '../../utils/marineTraffic'; @@ -79,9 +79,10 @@ interface GearToParentLink { interface Props { ships: Ship[]; + analysisMap?: Map; } -export function ChineseFishingOverlay({ ships }: Props) { +export function ChineseFishingOverlay({ ships, analysisMap }: Props) { // 중국 어선만 필터링 const chineseFishing = useMemo(() => { return ships.filter(s => { @@ -91,17 +92,43 @@ export function ChineseFishingOverlay({ ships }: Props) { }); }, [ships]); + // Python fleet_role → 표시용 role 매핑 + const resolveRole = (s: Ship): { role: string; roleKo: string; color: string } => { + const dto = analysisMap?.get(s.mmsi); + if (dto) { + const fleetRole = dto.algorithms.fleetRole.role; + const riskLevel = dto.algorithms.riskScore.level; + if (fleetRole === 'LEADER') { + return { role: 'PT', roleKo: '본선', color: riskLevel === 'CRITICAL' ? '#ef4444' : '#f97316' }; + } + if (fleetRole === 'MEMBER') { + return { role: 'PT-S', roleKo: '부속', color: '#fb923c' }; + } + } + return estimateRole(s); + }; + // 조업 분석 결과 const analyzed = useMemo(() => { return chineseFishing.map(s => ({ ship: s, analysis: analyzeFishing(s), - role: estimateRole(s), + role: resolveRole(s), })); - }, [chineseFishing]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chineseFishing, analysisMap]); // 조업 중인 선박만 (어구 아이콘 표시용, 최대 100척) - const operating = useMemo(() => analyzed.filter(a => a.analysis.isOperating).slice(0, 100), [analyzed]); + // Python activity_state === 'FISHING'인 선박도 조업 중으로 간주 + const operating = useMemo(() => { + return analyzed + .filter(a => { + if (a.analysis.isOperating) return true; + const dto = analysisMap?.get(a.ship.mmsi); + return dto?.algorithms.activity.state === 'FISHING'; + }) + .slice(0, 100); + }, [analyzed, analysisMap]); // 어구/어망 → 모선 연결 탐지 (거리 제한 + 정확 매칭 우선) const gearLinks: GearToParentLink[] = useMemo(() => { diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 807eb55..cea50a3 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -21,10 +21,13 @@ import { GovBuildingLayer } from './GovBuildingLayer'; import { NKLaunchLayer } from './NKLaunchLayer'; import { NKMissileEventLayer } from './NKMissileEventLayer'; import { ChineseFishingOverlay } from './ChineseFishingOverlay'; +import { AnalysisOverlay } from './AnalysisOverlay'; +import { AnalysisStatsPanel } from './AnalysisStatsPanel'; import { fetchKoreaInfra } from '../../services/infra'; import type { PowerFacility } from '../../services/infra'; import type { Ship, Aircraft, SatellitePosition } from '../../types'; import type { OsintItem } from '../../services/osint'; +import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis'; import { countryLabelsGeoJSON } from '../../data/countryLabels'; import 'maplibre-gl/dist/maplibre-gl.css'; @@ -49,6 +52,7 @@ interface Props { cableWatchSuspects: Set; dokdoWatchSuspects: Set; dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[]; + vesselAnalysis?: UseVesselAnalysisResult; } // MarineTraffic-style: satellite + dark ocean + nautical overlay @@ -121,7 +125,7 @@ const FILTER_I18N_KEY: Record = { ferryWatch: 'filters.ferryWatchMonitor', }; -export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts }: Props) { +export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); const [infra, setInfra] = useState([]); @@ -268,7 +272,20 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre {layers.govBuildings && } {layers.nkLaunch && } {layers.nkMissile && } - {layers.cnFishing && } + {layers.cnFishing && } + {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && ( + + )} {layers.airports && } {layers.coastGuard && } {layers.navWarning && } @@ -330,6 +347,15 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre ))}
)} + + {/* AI Analysis Stats Panel */} + {vesselAnalysis && vesselAnalysis.stats.total > 0 && ( + + )} ); } diff --git a/frontend/src/hooks/useKoreaFilters.ts b/frontend/src/hooks/useKoreaFilters.ts index 135da1d..2bbec27 100644 --- a/frontend/src/hooks/useKoreaFilters.ts +++ b/frontend/src/hooks/useKoreaFilters.ts @@ -1,7 +1,7 @@ import { useState, useMemo, useRef } from 'react'; import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable'; import { getMarineTrafficCategory } from '../utils/marineTraffic'; -import type { Ship } from '../types'; +import type { Ship, VesselAnalysisDto } from '../types'; interface KoreaFilters { illegalFishing: boolean; @@ -41,6 +41,7 @@ export function useKoreaFilters( koreaShips: Ship[], visibleShips: Ship[], currentTime: number, + analysisMap?: Map, ): UseKoreaFiltersResult { const [filters, setFilters] = useState({ illegalFishing: false, @@ -190,8 +191,17 @@ export function useKoreaFilters( } } + // Python 분류 결과 합집합: is_dark=true인 mmsi 추가 + if (analysisMap) { + for (const [mmsi, dto] of analysisMap.entries()) { + if (dto.algorithms.darkVessel.isDark) { + result.add(mmsi); + } + } + } + return result; - }, [koreaShips, filters.darkVessel, currentTime]); + }, [koreaShips, filters.darkVessel, currentTime, analysisMap]); // 해저케이블 감시 const cableWatchSet = useMemo(() => { @@ -298,6 +308,7 @@ export function useKoreaFilters( return visibleShips.filter(s => { const mtCat = getMarineTrafficCategory(s.typecode, s.category); if (filters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true; + if (filters.illegalFishing && (analysisMap?.get(s.mmsi)?.algorithms.riskScore.level === 'CRITICAL' || analysisMap?.get(s.mmsi)?.algorithms.riskScore.level === 'HIGH')) return true; if (filters.illegalTransship && transshipSuspects.has(s.mmsi)) return true; if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true; if (filters.cableWatch && cableWatchSet.has(s.mmsi)) return true; @@ -305,7 +316,7 @@ export function useKoreaFilters( if (filters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true; return false; }); - }, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]); + }, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap]); return { filters, diff --git a/frontend/src/hooks/useVesselAnalysis.ts b/frontend/src/hooks/useVesselAnalysis.ts new file mode 100644 index 0000000..ea53b32 --- /dev/null +++ b/frontend/src/hooks/useVesselAnalysis.ts @@ -0,0 +1,114 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import type { VesselAnalysisDto, RiskLevel } from '../types'; +import { fetchVesselAnalysis } from '../services/vesselAnalysis'; + +const POLL_INTERVAL_MS = 5 * 60_000; // 5분 +const STALE_MS = 30 * 60_000; // 30분 + +export interface AnalysisStats { + total: number; + critical: number; + high: number; + medium: number; + low: number; + dark: number; + spoofing: number; + clusterCount: number; +} + +export interface UseVesselAnalysisResult { + analysisMap: Map; + stats: AnalysisStats; + clusters: Map; + isLoading: boolean; + lastUpdated: number; +} + +const EMPTY_STATS: AnalysisStats = { + total: 0, critical: 0, high: 0, medium: 0, low: 0, + dark: 0, spoofing: 0, clusterCount: 0, +}; + +export function useVesselAnalysis(enabled: boolean): UseVesselAnalysisResult { + const mapRef = useRef>(new Map()); + const [version, setVersion] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [lastUpdated, setLastUpdated] = useState(0); + + const doFetch = useCallback(async () => { + if (!enabled) return; + setIsLoading(true); + try { + const items = await fetchVesselAnalysis(); + const now = Date.now(); + const map = mapRef.current; + + // stale 제거 + for (const [mmsi, dto] of map) { + const ts = new Date(dto.timestamp).getTime(); + if (now - ts > STALE_MS) map.delete(mmsi); + } + + // 새 결과 merge + for (const item of items) { + map.set(item.mmsi, item); + } + + setLastUpdated(now); + setVersion(v => v + 1); + } catch { + // 에러 시 기존 데이터 유지 (graceful degradation) + } finally { + setIsLoading(false); + } + }, [enabled]); + + useEffect(() => { + doFetch(); + const t = setInterval(doFetch, POLL_INTERVAL_MS); + return () => clearInterval(t); + }, [doFetch]); + + const analysisMap = mapRef.current; + + const stats = useMemo((): AnalysisStats => { + if (analysisMap.size === 0) return EMPTY_STATS; + let critical = 0, high = 0, medium = 0, low = 0, dark = 0, spoofing = 0; + const clusterIds = new Set(); + + for (const dto of analysisMap.values()) { + const level: RiskLevel = dto.algorithms.riskScore.level; + if (level === 'CRITICAL') critical++; + else if (level === 'HIGH') high++; + else if (level === 'MEDIUM') medium++; + else low++; + + if (dto.algorithms.darkVessel.isDark) dark++; + if (dto.algorithms.gpsSpoofing.spoofingScore > 0.5) spoofing++; + if (dto.algorithms.cluster.clusterId >= 0) { + clusterIds.add(dto.algorithms.cluster.clusterId); + } + } + + return { + total: analysisMap.size, critical, high, medium, low, + dark, spoofing, clusterCount: clusterIds.size, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [version]); + + const clusters = useMemo((): Map => { + const result = new Map(); + for (const [mmsi, dto] of analysisMap) { + const cid = dto.algorithms.cluster.clusterId; + if (cid < 0) continue; + const arr = result.get(cid); + if (arr) arr.push(mmsi); + else result.set(cid, [mmsi]); + } + return result; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [version]); + + return { analysisMap, stats, clusters, isLoading, lastUpdated }; +} diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts new file mode 100644 index 0000000..0399cb8 --- /dev/null +++ b/frontend/src/services/vesselAnalysis.ts @@ -0,0 +1,12 @@ +import type { VesselAnalysisDto } from '../types'; + +const API_BASE = '/api/kcg'; + +export async function fetchVesselAnalysis(): Promise { + const res = await fetch(`${API_BASE}/vessel-analysis`, { + headers: { accept: 'application/json' }, + }); + if (!res.ok) return []; + const data: { count: number; items: VesselAnalysisDto[] } = await res.json(); + return data.items ?? []; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 458e7fe..a873fa9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -147,3 +147,31 @@ export interface LayerVisibility { } export type AppMode = 'replay' | 'live'; + +// Vessel analysis (Python prediction 결과) +export type VesselType = 'TRAWL' | 'PURSE' | 'LONGLINE' | 'TRAP' | 'UNKNOWN'; +export type RiskLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'; +export type ActivityState = 'STATIONARY' | 'FISHING' | 'SAILING' | 'UNKNOWN'; +export type FleetRole = 'LEADER' | 'MEMBER' | 'NOISE'; + +export interface VesselAnalysisDto { + mmsi: string; + timestamp: string; + classification: { + vesselType: VesselType; + confidence: number; + fishingPct: number; + clusterId: number; + season: string; + }; + algorithms: { + location: { zone: string; distToBaselineNm: number }; + activity: { state: ActivityState; ucafScore: number; ucftScore: number }; + darkVessel: { isDark: boolean; gapDurationMin: number }; + gpsSpoofing: { spoofingScore: number; bd09OffsetM: number; speedJumpCount: number }; + cluster: { clusterId: number; clusterSize: number }; + fleetRole: { isLeader: boolean; role: FleetRole }; + riskScore: { score: number; level: RiskLevel }; + }; + features: Record; +}