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; lastUpdated: number; isLoading: boolean; analysisMap: Map; ships: Ship[]; allShips?: Ship[]; onShipSelect?: (mmsi: string) => void; onTrackLoad?: (mmsi: string, coords: [number, number][]) => void; } interface VesselListItem { mmsi: string; name: string; score: number; dto: VesselAnalysisDto; } /** 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}`; } 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']; 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, allShips, 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]); const gearStats = useMemo(() => { const source = allShips ?? ships; const gearPattern = /^(.+?)_\d+_\d+_?$/; const STALE_MS = 60 * 60_000; // 60๋ถ„ ์ด๋‚ด๋งŒ const now = Date.now(); const parentMap = new Map(); for (const s of source) { if (now - s.lastSeen > STALE_MS) continue; const m = (s.name || '').match(gearPattern); if (m) { const parent = m[1].trim(); parentMap.set(parent, (parentMap.get(parent) || 0) + 1); } } return { groups: parentMap.size, count: Array.from(parentMap.values()).reduce((a, b) => a + b, 0), }; }, [allShips, ships]); 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 = 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 = { position: 'absolute', top: 60, right: 10, zIndex: 10, minWidth: 200, maxWidth: 280, 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', display: 'flex', flexDirection: 'column', pointerEvents: 'auto', }; 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', flexShrink: 0, }; 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', overflowY: 'auto', flex: 1, }; 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: 4, justifyContent: 'space-between', marginTop: 4, }; const legendDividerStyle: React.CSSProperties = { ...dividerStyle, marginTop: 8, }; const legendBodyStyle: React.CSSProperties = { fontSize: 9, color: '#475569', lineHeight: 1.7, whiteSpace: 'pre', }; return (
{/* ํ—ค๋” */}
AI ๋ถ„์„ {isLoading && ( ๋กœ๋”ฉ์ค‘... )}
{formatTime(lastUpdated)}
{/* ๋ณธ๋ฌธ */} {expanded && (
{isEmpty ? (
๋ถ„์„ ๋ฐ์ดํ„ฐ ์—†์Œ
) : ( <> {/* ์š”์•ฝ ํ–‰ */}
์ „์ฒด {stats.total}
๋‹คํฌ๋ฒ ์…€ {stats.dark}
GPS์Šคํ‘ธํ•‘ {stats.spoofing}
์„ ๋‹จ์ˆ˜ {stats.clusterCount}
{gearStats.groups > 0 && ( <>
์–ด๊ตฌ๊ทธ๋ฃน {gearStats.groups}
์–ด๊ตฌ์ˆ˜ {gearStats.count}
)}
{/* ์œ„ํ—˜๋„ ์นด์šดํŠธ ํ–‰ โ€” ํด๋ฆญ ๊ฐ€๋Šฅ */}
{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 (
{/* ์„ ๋ฐ• ํ–‰ */}
{ void 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} {item.score}์  โ–ถ
{/* ๊ทผ๊ฑฐ ์ƒ์„ธ */} {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 && ( <>
ํ•ด๋‹น ๋ ˆ๋ฒจ ์„ ๋ฐ• ์—†์Œ
)} )} {/* ๋ฒ”๋ก€ */} {showLegend && ( <>
{LEGEND_LINES.map((line, i) => (
{line || '\u00A0'}
))}
)}
)}
); }