From 48c15f9c333df83bbb0665017d870a9129b884c3 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 15:42:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=EB=B6=84=EC=84=9D=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EA=B0=9C=EC=84=A0=20=E2=80=94=20=ED=95=AD=EC=A0=81?= =?UTF-8?q?=20API=20+=20=EB=B2=94=EB=A1=80=20+=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20+=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: mmsi별 최신 1건만 반환 (중복 제거) - 항적: signal-batch tracks API 호출 (6시간, 5분 캐시) - 범례: 위험도 점수 기준 상세 (위치/조업/AIS/허가, 0~100) - 선박 목록: maxHeight 300px 스크롤 가능 - 선박 클릭 → flyTo + 항적 표시 + 근거 상세 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../analysis/VesselAnalysisService.java | 18 ++++- .../components/korea/AnalysisStatsPanel.tsx | 77 +++++++++++++++++-- frontend/src/components/korea/KoreaMap.tsx | 46 ++++++----- frontend/src/services/vesselTrack.ts | 39 ++++++++++ 4 files changed, 148 insertions(+), 32 deletions(-) create mode 100644 frontend/src/services/vesselTrack.ts 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)} +