feat: AI 분석 패널 — 항적 API + 범례 + 스크롤 + 중복 제거 #118

병합
htlee feat/analysis-panel-interactive 에서 develop 로 1 commits 를 머지했습니다 2026-03-20 15:42:35 +09:00
4개의 변경된 파일148개의 추가작업 그리고 32개의 파일을 삭제

파일 보기

@ -8,7 +8,10 @@ import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
@ -18,7 +21,8 @@ public class VesselAnalysisService {
private final CacheManager cacheManager;
/**
* 최근 1시간 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용.
* 최근 1시간 분석 결과를 반환한다. mmsi별 최신 1건만.
* Caffeine 캐시(TTL 5분) 적용.
*/
@SuppressWarnings("unchecked")
public List<VesselAnalysisDto> getLatestResults() {
@ -30,10 +34,16 @@ public class VesselAnalysisService {
}
}
// 최근 1시간 이내 분석 결과 (Python 5분 주기 최대 12사이클 포함)
Instant since = Instant.now().minus(1, ChronoUnit.HOURS);
List<VesselAnalysisDto> results = repository.findByAnalyzedAtAfter(since)
.stream()
// mmsi별 최신 analyzed_at 1건만 유지
Map<String, VesselAnalysisResult> latest = new LinkedHashMap<>();
for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) {
latest.merge(r.getMmsi(), r, (old, cur) ->
cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old);
}
List<VesselAnalysisDto> results = latest.values().stream()
.sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed())
.map(VesselAnalysisDto::from)
.toList();

파일 보기

@ -1,6 +1,7 @@
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;
@ -9,6 +10,7 @@ interface Props {
analysisMap: Map<string, VesselAnalysisDto>;
ships: Ship[];
onShipSelect?: (mmsi: string) => void;
onTrackLoad?: (mmsi: string, coords: [number, number][]) => void;
}
interface VesselListItem {
@ -43,10 +45,36 @@ const RISK_EMOJI: Record<RiskLevel, string> = {
const RISK_LEVELS: RiskLevel[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, onShipSelect }: Props) {
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, onShipSelect, onTrackLoad }: Props) {
const [expanded, setExpanded] = useState(true);
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]);
@ -66,9 +94,11 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
setSelectedMmsi(null);
};
const handleVesselClick = (mmsi: string) => {
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 = {
@ -78,7 +108,6 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
zIndex: 10,
minWidth: 200,
maxWidth: 280,
maxHeight: 500,
backgroundColor: 'rgba(12, 24, 37, 0.92)',
border: '1px solid rgba(99, 179, 237, 0.25)',
borderRadius: 8,
@ -147,6 +176,18 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
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}>
{/* 헤더 */}
@ -157,8 +198,16 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
<span style={{ fontSize: 9, color: '#fbbf24' }}>...</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<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={() => setExpanded(prev => !prev)}
@ -236,7 +285,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 4 }}>
{RISK_EMOJI[selectedLevel]} {selectedLevel} {vesselList.length}
</div>
<div>
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
{vesselList.map(item => {
const isExpanded = selectedMmsi === item.mmsi;
const color = RISK_COLOR[selectedLevel];
@ -245,7 +294,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
<div key={item.mmsi}>
{/* 선박 행 */}
<div
onClick={() => handleVesselClick(item.mmsi)}
onClick={() => { void handleVesselClick(item.mmsi); }}
style={{
display: 'flex',
alignItems: 'center',
@ -271,7 +320,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
{item.mmsi}
</span>
<span style={{ color, fontWeight: 700, fontSize: 10, flexShrink: 0 }}>
{Math.round(item.score * 100)}
{Math.round(item.score * 100)}
</span>
<span style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0 }}></span>
</div>
@ -326,6 +375,20 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
)}
</>
)}
{/* 범례 */}
{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>

파일 보기

@ -135,6 +135,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const [infra, setInfra] = useState<PowerFacility[]>([]);
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null);
useEffect(() => {
fetchKoreaInfra().then(setInfra).catch(() => {});
@ -147,12 +148,20 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
}
}, [flyToTarget]);
useEffect(() => {
if (!selectedAnalysisMmsi) setTrackCoords(null);
}, [selectedAnalysisMmsi]);
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]);
const handleTrackLoad = useCallback((_mmsi: string, coords: [number, number][]) => {
setTrackCoords(coords);
}, []);
return (
<Map
ref={mapRef}
@ -392,32 +401,26 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
</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,
{/* 선택된 분석 선박 항적 — tracks API 응답 기반 */}
{trackCoords && trackCoords.length > 1 && (
<Source id="analysis-trail" type="geojson" data={{
type: 'FeatureCollection',
features: [{
type: 'Feature' as const,
type: 'Feature',
properties: {},
geometry: {
type: 'LineString' as const,
coordinates: ship.trail.map(([lat, lng]) => [lng, lat]),
type: 'LineString',
coordinates: trackCoords,
},
}],
};
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>
);
})()}
}}>
<Layer id="analysis-trail-line" type="line" paint={{
'line-color': '#00e5ff',
'line-width': 2.5,
'line-opacity': 0.8,
}} />
</Source>
)}
{/* AI Analysis Stats Panel — 항상 표시 */}
{vesselAnalysis && (
@ -428,6 +431,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
analysisMap={vesselAnalysis.analysisMap}
ships={allShips ?? ships}
onShipSelect={handleAnalysisShipSelect}
onTrackLoad={handleTrackLoad}
/>
)}
</Map>

파일 보기

@ -0,0 +1,39 @@
const SIGNAL_BATCH_BASE = '/signal-batch';
interface TrackResponse {
vesselId: string;
geometry: [number, number][];
speeds: number[];
timestamps: string[];
pointCount: number;
totalDistance: number;
shipName: string;
}
// mmsi별 캐시 (TTL 5분)
const trackCache = new Map<string, { time: number; coords: [number, number][] }>();
const CACHE_TTL = 5 * 60_000;
export async function fetchVesselTrack(mmsi: string, hours: number = 6): Promise<[number, number][]> {
const cached = trackCache.get(mmsi);
if (cached && Date.now() - cached.time < CACHE_TTL) return cached.coords;
const endTime = new Date().toISOString();
const startTime = new Date(Date.now() - hours * 3600_000).toISOString();
try {
const res = await fetch(`${SIGNAL_BATCH_BASE}/api/v2/tracks/vessels`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ startTime, endTime, vessels: [mmsi] }),
});
if (!res.ok) return [];
const data: TrackResponse[] = await res.json();
if (!data.length || !data[0].geometry?.length) return [];
const coords = data[0].geometry;
trackCache.set(mmsi, { time: Date.now(), coords });
return coords;
} catch {
return [];
}
}