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 && ( )}