From fe133b142ebaf9b3e6f25e9b3eca0fbbf3c8fd7c Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 15:22:06 +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=EC=9D=B8=ED=84=B0=EB=9E=99=ED=8B=B0=EB=B8=8C=20?= =?UTF-8?q?=E2=80=94=20=EC=84=A0=EB=B0=95=20=EB=AA=A9=EB=A1=9D=20+=20flyTo?= =?UTF-8?q?=20+=20=EA=B7=BC=EA=B1=B0=20=EC=83=81=EC=84=B8=20+=20=ED=95=AD?= =?UTF-8?q?=EC=A0=81=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 위험도 버튼 클릭 → 해당 레벨 선박 목록 펼침 (최대 50척) - 선박 행 클릭 → 지도 중심이동(flyTo) + 근거 상세 펼침 - 근거: 위치/활동/다크/GPS/선단 정보 표시 - 선택 선박 항적: trail 데이터를 GeoJSON LineString으로 렌더링 - KoreaMap flyTo 기능 구현 (mapRef.flyTo) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/korea/AnalysisStatsPanel.tsx | 214 +++++++++++++++--- frontend/src/components/korea/KoreaMap.tsx | 47 +++- 2 files changed, 232 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx index 016d6b1..19ae44b 100644 --- a/frontend/src/components/korea/AnalysisStatsPanel.tsx +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -1,10 +1,21 @@ import { useState, useMemo } from 'react'; +import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types'; import type { AnalysisStats } from '../../hooks/useVesselAnalysis'; interface Props { stats: AnalysisStats; lastUpdated: number; isLoading: boolean; + analysisMap: Map; + ships: Ship[]; + onShipSelect?: (mmsi: string) => void; +} + +interface VesselListItem { + mmsi: string; + name: string; + score: number; + dto: VesselAnalysisDto; } /** unix ms → HH:MM 형식 */ @@ -16,17 +27,58 @@ function formatTime(ms: number): string { return `${hh}:${mm}`; } -export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) { +const RISK_COLOR: Record = { + CRITICAL: '#ef4444', + HIGH: '#f97316', + MEDIUM: '#eab308', + LOW: '#22c55e', +}; + +const RISK_EMOJI: Record = { + CRITICAL: '🔴', + HIGH: '🟠', + MEDIUM: '🟡', + LOW: '🟢', +}; + +const RISK_LEVELS: RiskLevel[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; + +export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, onShipSelect }: Props) { const [expanded, setExpanded] = useState(true); + const [selectedLevel, setSelectedLevel] = useState(null); + const [selectedMmsi, setSelectedMmsi] = useState(null); const isEmpty = useMemo(() => stats.total === 0, [stats.total]); + const vesselList = useMemo((): VesselListItem[] => { + if (!selectedLevel) return []; + const list: VesselListItem[] = []; + for (const [mmsi, dto] of analysisMap) { + if (dto.algorithms.riskScore.level !== selectedLevel) continue; + const ship = ships.find(s => s.mmsi === mmsi); + list.push({ mmsi, name: ship?.name || mmsi, score: dto.algorithms.riskScore.score, dto }); + } + return list.sort((a, b) => b.score - a.score).slice(0, 50); + }, [selectedLevel, analysisMap, ships]); + + const handleLevelClick = (level: RiskLevel) => { + setSelectedLevel(prev => (prev === level ? null : level)); + setSelectedMmsi(null); + }; + + const handleVesselClick = (mmsi: string) => { + setSelectedMmsi(prev => (prev === mmsi ? null : mmsi)); + onShipSelect?.(mmsi); + }; + const panelStyle: React.CSSProperties = { position: 'absolute', top: 60, right: 10, zIndex: 10, - minWidth: 160, + minWidth: 200, + maxWidth: 280, + maxHeight: 500, backgroundColor: 'rgba(12, 24, 37, 0.92)', border: '1px solid rgba(99, 179, 237, 0.25)', borderRadius: 8, @@ -35,6 +87,9 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) { fontSize: 11, boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)', overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + pointerEvents: 'auto', }; const headerStyle: React.CSSProperties = { @@ -45,6 +100,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) { borderBottom: expanded ? '1px solid rgba(99, 179, 237, 0.15)' : 'none', cursor: 'default', userSelect: 'none', + flexShrink: 0, }; const toggleButtonStyle: React.CSSProperties = { @@ -59,6 +115,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) { const bodyStyle: React.CSSProperties = { padding: '8px 10px', + overflowY: 'auto', + flex: 1, }; const rowStyle: React.CSSProperties = { @@ -84,19 +142,11 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) { const riskRowStyle: React.CSSProperties = { display: 'flex', - gap: 6, + gap: 4, justifyContent: 'space-between', marginTop: 4, }; - const riskBadgeStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - gap: 2, - color: '#cbd5e1', - fontSize: 10, - }; - return (
{/* 헤더 */} @@ -128,6 +178,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) {
) : ( <> + {/* 요약 행 */}
전체 {stats.total} @@ -147,25 +198,132 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) {
- {/* 위험도 수치 행 */} + {/* 위험도 카운트 행 — 클릭 가능 */}
-
- 🔴 - {stats.critical} -
-
- 🟠 - {stats.high} -
-
- 🟡 - {stats.medium} -
-
- 🟢 - {stats.low} -
+ {RISK_LEVELS.map(level => { + const count = stats[level.toLowerCase() as 'critical' | 'high' | 'medium' | 'low']; + const isActive = selectedLevel === level; + return ( + + ); + })}
+ + {/* 선박 목록 */} + {selectedLevel !== null && vesselList.length > 0 && ( + <> +
+
+ {RISK_EMOJI[selectedLevel]} {selectedLevel} — {vesselList.length}척 +
+
+ {vesselList.map(item => { + const isExpanded = selectedMmsi === item.mmsi; + const color = RISK_COLOR[selectedLevel]; + const { dto } = item; + return ( +
+ {/* 선박 행 */} +
handleVesselClick(item.mmsi)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 4, + padding: '3px 4px', + cursor: 'pointer', + borderRadius: 3, + borderLeft: isExpanded ? `2px solid ${color}` : '2px solid transparent', + backgroundColor: isExpanded ? 'rgba(255,255,255,0.06)' : 'transparent', + transition: 'background-color 0.1s', + }} + onMouseEnter={e => { + if (!isExpanded) (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(255,255,255,0.04)'; + }} + onMouseLeave={e => { + if (!isExpanded) (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; + }} + > + + {item.name} + + + {item.mmsi} + + + {Math.round(item.score * 100)} + + +
+ + {/* 근거 상세 */} + {isExpanded && ( +
+
+ 위치: {dto.algorithms.location.zone} + {' '}(기선 {dto.algorithms.location.distToBaselineNm.toFixed(1)}NM) +
+
+ 활동: {dto.algorithms.activity.state} + {' '}(UCAF {dto.algorithms.activity.ucafScore.toFixed(2)}) +
+ {dto.algorithms.darkVessel.isDark && ( +
다크: {dto.algorithms.darkVessel.gapDurationMin}분 갭
+ )} + {dto.algorithms.gpsSpoofing.spoofingScore > 0 && ( +
+ GPS: 스푸핑 {Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100)}% +
+ )} + {dto.algorithms.cluster.clusterSize > 1 && ( +
+ 선단: {dto.algorithms.fleetRole.role} + {' '}({dto.algorithms.cluster.clusterSize}척) +
+ )} +
+ )} +
+ ); + })} +
+ + )} + + {selectedLevel !== null && vesselList.length === 0 && ( + <> +
+
+ 해당 레벨 선박 없음 +
+ + )} )}
diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index a7e9aa7..c06934a 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useEffect } from 'react'; +import { useRef, useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; @@ -133,11 +133,26 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const { t } = useTranslation(); const mapRef = useRef(null); const [infra, setInfra] = useState([]); + const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null); + const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState(null); useEffect(() => { fetchKoreaInfra().then(setInfra).catch(() => {}); }, []); + useEffect(() => { + if (flyToTarget && mapRef.current) { + mapRef.current.flyTo({ center: [flyToTarget.lng, flyToTarget.lat], zoom: flyToTarget.zoom, duration: 1500 }); + setFlyToTarget(null); + } + }, [flyToTarget]); + + const handleAnalysisShipSelect = useCallback((mmsi: string) => { + setSelectedAnalysisMmsi(mmsi); + const ship = (allShips ?? ships).find(s => s.mmsi === mmsi); + if (ship) setFlyToTarget({ lng: ship.lng, lat: ship.lat, zoom: 12 }); + }, [allShips, ships]); + return ( )} + {/* 선택된 분석 선박 항적 */} + {selectedAnalysisMmsi && (() => { + const ship = (allShips ?? ships).find(s => s.mmsi === selectedAnalysisMmsi); + if (!ship?.trail || ship.trail.length < 2) return null; + const trailGeoJson = { + type: 'FeatureCollection' as const, + features: [{ + type: 'Feature' as const, + properties: {}, + geometry: { + type: 'LineString' as const, + coordinates: ship.trail.map(([lat, lng]) => [lng, lat]), + }, + }], + }; + return ( + + + + ); + })()} + {/* AI Analysis Stats Panel — 항상 표시 */} {vesselAnalysis && ( )}