Merge pull request 'feat: AI 분석 패널 인터랙티브 — 선박 목록 + flyTo + 항적' (#116) from feat/analysis-panel-interactive into develop
This commit is contained in:
커밋
1ef39c5210
@ -1,10 +1,21 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types';
|
||||||
import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
|
import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
stats: AnalysisStats;
|
stats: AnalysisStats;
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
analysisMap: Map<string, VesselAnalysisDto>;
|
||||||
|
ships: Ship[];
|
||||||
|
onShipSelect?: (mmsi: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VesselListItem {
|
||||||
|
mmsi: string;
|
||||||
|
name: string;
|
||||||
|
score: number;
|
||||||
|
dto: VesselAnalysisDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** unix ms → HH:MM 형식 */
|
/** unix ms → HH:MM 형식 */
|
||||||
@ -16,17 +27,58 @@ function formatTime(ms: number): string {
|
|||||||
return `${hh}:${mm}`;
|
return `${hh}:${mm}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) {
|
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'];
|
||||||
|
|
||||||
|
export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, onShipSelect }: Props) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const [selectedLevel, setSelectedLevel] = useState<RiskLevel | null>(null);
|
||||||
|
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||||
|
|
||||||
const isEmpty = useMemo(() => stats.total === 0, [stats.total]);
|
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 = {
|
const panelStyle: React.CSSProperties = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 60,
|
top: 60,
|
||||||
right: 10,
|
right: 10,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
minWidth: 160,
|
minWidth: 200,
|
||||||
|
maxWidth: 280,
|
||||||
|
maxHeight: 500,
|
||||||
backgroundColor: 'rgba(12, 24, 37, 0.92)',
|
backgroundColor: 'rgba(12, 24, 37, 0.92)',
|
||||||
border: '1px solid rgba(99, 179, 237, 0.25)',
|
border: '1px solid rgba(99, 179, 237, 0.25)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@ -35,6 +87,9 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) {
|
|||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
|
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
pointerEvents: 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
const headerStyle: React.CSSProperties = {
|
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',
|
borderBottom: expanded ? '1px solid rgba(99, 179, 237, 0.15)' : 'none',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
flexShrink: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleButtonStyle: React.CSSProperties = {
|
const toggleButtonStyle: React.CSSProperties = {
|
||||||
@ -59,6 +115,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) {
|
|||||||
|
|
||||||
const bodyStyle: React.CSSProperties = {
|
const bodyStyle: React.CSSProperties = {
|
||||||
padding: '8px 10px',
|
padding: '8px 10px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
flex: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const rowStyle: React.CSSProperties = {
|
const rowStyle: React.CSSProperties = {
|
||||||
@ -84,19 +142,11 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) {
|
|||||||
|
|
||||||
const riskRowStyle: React.CSSProperties = {
|
const riskRowStyle: React.CSSProperties = {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 6,
|
gap: 4,
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
const riskBadgeStyle: React.CSSProperties = {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 2,
|
|
||||||
color: '#cbd5e1',
|
|
||||||
fontSize: 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={panelStyle}>
|
<div style={panelStyle}>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
@ -128,6 +178,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* 요약 행 */}
|
||||||
<div style={rowStyle}>
|
<div style={rowStyle}>
|
||||||
<span style={labelStyle}>전체</span>
|
<span style={labelStyle}>전체</span>
|
||||||
<span style={valueStyle}>{stats.total}</span>
|
<span style={valueStyle}>{stats.total}</span>
|
||||||
@ -147,25 +198,132 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) {
|
|||||||
|
|
||||||
<div style={dividerStyle} />
|
<div style={dividerStyle} />
|
||||||
|
|
||||||
{/* 위험도 수치 행 */}
|
{/* 위험도 카운트 행 — 클릭 가능 */}
|
||||||
<div style={riskRowStyle}>
|
<div style={riskRowStyle}>
|
||||||
<div style={riskBadgeStyle}>
|
{RISK_LEVELS.map(level => {
|
||||||
<span>🔴</span>
|
const count = stats[level.toLowerCase() as 'critical' | 'high' | 'medium' | 'low'];
|
||||||
<span style={{ color: '#ef4444', fontWeight: 700 }}>{stats.critical}</span>
|
const isActive = selectedLevel === level;
|
||||||
</div>
|
return (
|
||||||
<div style={riskBadgeStyle}>
|
<button
|
||||||
<span>🟠</span>
|
key={level}
|
||||||
<span style={{ color: '#f97316', fontWeight: 700 }}>{stats.high}</span>
|
type="button"
|
||||||
</div>
|
onClick={() => handleLevelClick(level)}
|
||||||
<div style={riskBadgeStyle}>
|
style={{
|
||||||
<span>🟡</span>
|
display: 'flex',
|
||||||
<span style={{ color: '#eab308', fontWeight: 700 }}>{stats.medium}</span>
|
alignItems: 'center',
|
||||||
</div>
|
gap: 2,
|
||||||
<div style={riskBadgeStyle}>
|
background: isActive ? `${RISK_COLOR[level]}22` : 'none',
|
||||||
<span>🟢</span>
|
border: isActive ? `1px solid ${RISK_COLOR[level]}88` : '1px solid transparent',
|
||||||
<span style={{ color: '#22c55e', fontWeight: 700 }}>{stats.low}</span>
|
borderRadius: 4,
|
||||||
</div>
|
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>
|
</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>
|
||||||
|
{vesselList.map(item => {
|
||||||
|
const isExpanded = selectedMmsi === item.mmsi;
|
||||||
|
const color = RISK_COLOR[selectedLevel];
|
||||||
|
const { dto } = item;
|
||||||
|
return (
|
||||||
|
<div key={item.mmsi}>
|
||||||
|
{/* 선박 행 */}
|
||||||
|
<div
|
||||||
|
onClick={() => 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 }}>
|
||||||
|
{Math.round(item.score * 100)}
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState, useEffect } from 'react';
|
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
|
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
|
||||||
import type { MapRef } 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 { t } = useTranslation();
|
||||||
const mapRef = useRef<MapRef>(null);
|
const mapRef = useRef<MapRef>(null);
|
||||||
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
||||||
|
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
|
||||||
|
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchKoreaInfra().then(setInfra).catch(() => {});
|
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 (
|
return (
|
||||||
<Map
|
<Map
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
@ -377,12 +392,42 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 선택된 분석 선박 항적 */}
|
||||||
|
{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 (
|
||||||
|
<Source id="analysis-trail" type="geojson" data={trailGeoJson}>
|
||||||
|
<Layer id="analysis-trail-line" type="line" paint={{
|
||||||
|
'line-color': '#00e5ff',
|
||||||
|
'line-width': 2.5,
|
||||||
|
'line-opacity': 0.8,
|
||||||
|
'line-dasharray': [2, 1],
|
||||||
|
}} />
|
||||||
|
</Source>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* AI Analysis Stats Panel — 항상 표시 */}
|
{/* AI Analysis Stats Panel — 항상 표시 */}
|
||||||
{vesselAnalysis && (
|
{vesselAnalysis && (
|
||||||
<AnalysisStatsPanel
|
<AnalysisStatsPanel
|
||||||
stats={vesselAnalysis.stats}
|
stats={vesselAnalysis.stats}
|
||||||
lastUpdated={vesselAnalysis.lastUpdated}
|
lastUpdated={vesselAnalysis.lastUpdated}
|
||||||
isLoading={vesselAnalysis.isLoading}
|
isLoading={vesselAnalysis.isLoading}
|
||||||
|
analysisMap={vesselAnalysis.analysisMap}
|
||||||
|
ships={allShips ?? ships}
|
||||||
|
onShipSelect={handleAnalysisShipSelect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Map>
|
</Map>
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user