Merge pull request 'release: AI 분석 패널 개선' (#119) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m49s
All checks were successful
Deploy KCG / deploy (push) Successful in 1m49s
This commit is contained in:
커밋
7b31f93d86
@ -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>
|
||||
);
|
||||
})()}
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
39
frontend/src/services/vesselTrack.ts
Normal file
39
frontend/src/services/vesselTrack.ts
Normal file
@ -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 [];
|
||||
}
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user