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 4cac4d1..775065e 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 @@ -8,7 +8,10 @@ import org.springframework.stereotype.Service; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -18,7 +21,8 @@ public class VesselAnalysisService { private final CacheManager cacheManager; /** - * 최근 1시간 내 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용. + * 최근 1시간 내 분석 결과를 반환한다. mmsi별 최신 1건만. + * Caffeine 캐시(TTL 5분) 적용. */ @SuppressWarnings("unchecked") public List getLatestResults() { @@ -30,10 +34,16 @@ public class VesselAnalysisService { } } - // 최근 1시간 이내 분석 결과 (Python 5분 주기 → 최대 12사이클 포함) Instant since = Instant.now().minus(1, ChronoUnit.HOURS); - List results = repository.findByAnalyzedAtAfter(since) - .stream() + // mmsi별 최신 analyzed_at 1건만 유지 + Map latest = new LinkedHashMap<>(); + for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) { + latest.merge(r.getMmsi(), r, (old, cur) -> + cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old); + } + + List results = latest.values().stream() + .sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed()) .map(VesselAnalysisDto::from) .toList(); diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx index 19ae44b..34e8c40 100644 --- a/frontend/src/components/korea/AnalysisStatsPanel.tsx +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -1,6 +1,7 @@ import { useState, useMemo } from 'react'; import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types'; import type { AnalysisStats } from '../../hooks/useVesselAnalysis'; +import { fetchVesselTrack } from '../../services/vesselTrack'; interface Props { stats: AnalysisStats; @@ -9,6 +10,7 @@ interface Props { analysisMap: Map; ships: Ship[]; onShipSelect?: (mmsi: string) => void; + onTrackLoad?: (mmsi: string, coords: [number, number][]) => void; } interface VesselListItem { @@ -43,10 +45,36 @@ const RISK_EMOJI: Record = { const RISK_LEVELS: RiskLevel[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; -export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, onShipSelect }: Props) { +const LEGEND_LINES = [ + '위험도 점수 기준 (0~100)', + '', + '■ 위치 (최대 40점)', + ' 영해 내: 40 / 접속수역: 10', + '', + '■ 조업 행위 (최대 30점)', + ' 영해 내 조업: 20 / 기타 조업: 5', + ' U-turn 패턴: 10', + '', + '■ AIS 조작 (최대 35점)', + ' 순간이동: 20 / 장시간 갭: 15', + ' 단시간 갭: 5', + '', + '■ 허가 이력 (최대 20점)', + ' 미허가 어선: 20', + '', + 'CRITICAL ≥70 / HIGH ≥50', + 'MEDIUM ≥30 / LOW <30', + '', + 'UCAF: 어구별 조업속도 매칭 비율', + 'UCFT: 조업-항행 구분 신뢰도', + '스푸핑: 순간이동+SOG급변+BD09 종합', +]; + +export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, onShipSelect, onTrackLoad }: Props) { const [expanded, setExpanded] = useState(true); const [selectedLevel, setSelectedLevel] = useState(null); const [selectedMmsi, setSelectedMmsi] = useState(null); + const [showLegend, setShowLegend] = useState(false); const isEmpty = useMemo(() => stats.total === 0, [stats.total]); @@ -66,9 +94,11 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, setSelectedMmsi(null); }; - const handleVesselClick = (mmsi: string) => { + const handleVesselClick = async (mmsi: string) => { setSelectedMmsi(prev => (prev === mmsi ? null : mmsi)); onShipSelect?.(mmsi); + const coords = await fetchVesselTrack(mmsi); + if (coords.length > 0) onTrackLoad?.(mmsi, coords); }; const panelStyle: React.CSSProperties = { @@ -78,7 +108,6 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, zIndex: 10, minWidth: 200, maxWidth: 280, - maxHeight: 500, backgroundColor: 'rgba(12, 24, 37, 0.92)', border: '1px solid rgba(99, 179, 237, 0.25)', borderRadius: 8, @@ -147,6 +176,18 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, marginTop: 4, }; + const legendDividerStyle: React.CSSProperties = { + ...dividerStyle, + marginTop: 8, + }; + + const legendBodyStyle: React.CSSProperties = { + fontSize: 9, + color: '#475569', + lineHeight: 1.7, + whiteSpace: 'pre', + }; + return (
{/* 헤더 */} @@ -157,8 +198,16 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, 로딩중... )}
-
+
{formatTime(lastUpdated)} +