440 lines
16 KiB
TypeScript
440 lines
16 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react';
|
|
import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types';
|
|
import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
|
|
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
|
import { fetchVesselTrack } from '../../services/vesselTrack';
|
|
|
|
interface Props {
|
|
stats: AnalysisStats;
|
|
lastUpdated: number;
|
|
isLoading: boolean;
|
|
analysisMap: Map<string, VesselAnalysisDto>;
|
|
ships: Ship[];
|
|
allShips?: Ship[];
|
|
onShipSelect?: (mmsi: string) => void;
|
|
onTrackLoad?: (mmsi: string, coords: [number, number][]) => void;
|
|
onExpandedChange?: (expanded: boolean) => 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<RiskLevel, string> = {
|
|
CRITICAL: '#ef4444',
|
|
HIGH: '#f97316',
|
|
MEDIUM: '#eab308',
|
|
LOW: '#22c55e',
|
|
};
|
|
|
|
const RISK_EMOJI: Record<RiskLevel, string> = {
|
|
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, onExpandedChange }: Props) {
|
|
const [expanded, setExpanded] = useLocalStorage('analysisPanelExpanded', false);
|
|
const toggleExpanded = () => {
|
|
const next = !expanded;
|
|
setExpanded(next);
|
|
onExpandedChange?.(next);
|
|
};
|
|
// 마운트 시 저장된 상태를 부모에 동기화
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
useEffect(() => { onExpandedChange?.(expanded); }, []);
|
|
const [selectedLevel, setSelectedLevel] = useState<RiskLevel | null>(null);
|
|
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(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<string, number>();
|
|
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: 10,
|
|
right: 50,
|
|
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 (
|
|
<div style={panelStyle}>
|
|
{/* 헤더 */}
|
|
<div style={headerStyle}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>AI 분석</span>
|
|
{isLoading && (
|
|
<span style={{ fontSize: 9, color: '#fbbf24' }}>로딩중...</span>
|
|
)}
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<span style={{ fontSize: 9, color: '#64748b' }}>{formatTime(lastUpdated)}</span>
|
|
<button
|
|
style={toggleButtonStyle}
|
|
onClick={() => setShowLegend(prev => !prev)}
|
|
aria-label="범례 보기"
|
|
title="범례"
|
|
>
|
|
?
|
|
</button>
|
|
<button
|
|
style={toggleButtonStyle}
|
|
onClick={toggleExpanded}
|
|
aria-label={expanded ? '패널 접기' : '패널 펼치기'}
|
|
>
|
|
{expanded ? '▲' : '▼'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 본문 */}
|
|
{expanded && (
|
|
<div style={bodyStyle}>
|
|
{isEmpty ? (
|
|
<div style={{ color: '#64748b', textAlign: 'center', padding: '6px 0' }}>
|
|
분석 데이터 없음
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 요약 행 */}
|
|
<div style={rowStyle}>
|
|
<span style={labelStyle}>전체</span>
|
|
<span style={valueStyle}>{stats.total}</span>
|
|
</div>
|
|
<div style={rowStyle}>
|
|
<span style={labelStyle}>다크베셀</span>
|
|
<span style={{ ...valueStyle, color: '#a855f7' }}>{stats.dark}</span>
|
|
</div>
|
|
<div style={rowStyle}>
|
|
<span style={labelStyle}>GPS스푸핑</span>
|
|
<span style={{ ...valueStyle, color: '#ef4444' }}>{stats.spoofing}</span>
|
|
</div>
|
|
<div style={rowStyle}>
|
|
<span style={labelStyle}>선단수</span>
|
|
<span style={{ ...valueStyle, color: '#06b6d4' }}>{stats.clusterCount}</span>
|
|
</div>
|
|
{gearStats.groups > 0 && (
|
|
<>
|
|
<div style={rowStyle}>
|
|
<span style={labelStyle}>어구그룹</span>
|
|
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.groups}</span>
|
|
</div>
|
|
<div style={rowStyle}>
|
|
<span style={labelStyle}>어구수</span>
|
|
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.count}</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div style={dividerStyle} />
|
|
|
|
{/* 위험도 카운트 행 — 클릭 가능 */}
|
|
<div style={riskRowStyle}>
|
|
{RISK_LEVELS.map(level => {
|
|
const count = stats[level.toLowerCase() as 'critical' | 'high' | 'medium' | 'low'];
|
|
const isActive = selectedLevel === level;
|
|
return (
|
|
<button
|
|
key={level}
|
|
type="button"
|
|
onClick={() => handleLevelClick(level)}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 2,
|
|
background: isActive ? `${RISK_COLOR[level]}22` : 'none',
|
|
border: isActive ? `1px solid ${RISK_COLOR[level]}88` : '1px solid transparent',
|
|
borderRadius: 4,
|
|
color: '#cbd5e1',
|
|
fontSize: 10,
|
|
cursor: 'pointer',
|
|
padding: '2px 4px',
|
|
fontFamily: 'monospace, sans-serif',
|
|
}}
|
|
>
|
|
<span>{RISK_EMOJI[level]}</span>
|
|
<span style={{ color: RISK_COLOR[level], fontWeight: 700 }}>{count}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 선박 목록 */}
|
|
{selectedLevel !== null && vesselList.length > 0 && (
|
|
<>
|
|
<div style={{ ...dividerStyle, marginTop: 8 }} />
|
|
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 4 }}>
|
|
{RISK_EMOJI[selectedLevel]} {selectedLevel} — {vesselList.length}척
|
|
</div>
|
|
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
|
|
{vesselList.map(item => {
|
|
const isExpanded = selectedMmsi === item.mmsi;
|
|
const color = RISK_COLOR[selectedLevel];
|
|
const { dto } = item;
|
|
return (
|
|
<div key={item.mmsi}>
|
|
{/* 선박 행 */}
|
|
<div
|
|
onClick={() => { 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';
|
|
}}
|
|
>
|
|
<span style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{item.name}
|
|
</span>
|
|
<span style={{ color: '#64748b', fontSize: 9, flexShrink: 0 }}>
|
|
{item.mmsi}
|
|
</span>
|
|
<span style={{ color, fontWeight: 700, fontSize: 10, flexShrink: 0 }}>
|
|
{item.score}점
|
|
</span>
|
|
<span style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0 }}>▶</span>
|
|
</div>
|
|
|
|
{/* 근거 상세 */}
|
|
{isExpanded && (
|
|
<div style={{
|
|
paddingLeft: 12,
|
|
paddingBottom: 4,
|
|
fontSize: 9,
|
|
color: '#64748b',
|
|
lineHeight: 1.6,
|
|
}}>
|
|
<div>
|
|
위치: {dto.algorithms.location.zone}
|
|
{' '}(기선 {dto.algorithms.location.distToBaselineNm.toFixed(1)}NM)
|
|
</div>
|
|
<div>
|
|
활동: {dto.algorithms.activity.state}
|
|
{' '}(UCAF {dto.algorithms.activity.ucafScore.toFixed(2)})
|
|
</div>
|
|
{dto.algorithms.darkVessel.isDark && (
|
|
<div>다크: {dto.algorithms.darkVessel.gapDurationMin}분 갭</div>
|
|
)}
|
|
{dto.algorithms.gpsSpoofing.spoofingScore > 0 && (
|
|
<div>
|
|
GPS: 스푸핑 {Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100)}%
|
|
</div>
|
|
)}
|
|
{dto.algorithms.cluster.clusterSize > 1 && (
|
|
<div>
|
|
선단: {dto.algorithms.fleetRole.role}
|
|
{' '}({dto.algorithms.cluster.clusterSize}척)
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{selectedLevel !== null && vesselList.length === 0 && (
|
|
<>
|
|
<div style={{ ...dividerStyle, marginTop: 8 }} />
|
|
<div style={{ fontSize: 9, color: '#64748b', textAlign: 'center', padding: '4px 0' }}>
|
|
해당 레벨 선박 없음
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* 범례 */}
|
|
{showLegend && (
|
|
<>
|
|
<div style={legendDividerStyle} />
|
|
<div style={legendBodyStyle}>
|
|
{LEGEND_LINES.map((line, i) => (
|
|
<div key={i} style={{ color: line.startsWith('■') ? '#64748b' : '#475569' }}>
|
|
{line || '\u00A0'}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|