feat: AI 분석 패널 개선 — 항적 API + 범례 + 스크롤 + 중복 제거
- Backend: mmsi별 최신 1건만 반환 (중복 제거) - 항적: signal-batch tracks API 호출 (6시간, 5분 캐시) - 범례: 위험도 점수 기준 상세 (위치/조업/AIS/허가, 0~100) - 선박 목록: maxHeight 300px 스크롤 가능 - 선박 클릭 → flyTo + 항적 표시 + 근거 상세 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
fe133b142e
커밋
48c15f9c33
@ -8,7 +8,10 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -18,7 +21,8 @@ public class VesselAnalysisService {
|
|||||||
private final CacheManager cacheManager;
|
private final CacheManager cacheManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최근 1시간 내 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용.
|
* 최근 1시간 내 분석 결과를 반환한다. mmsi별 최신 1건만.
|
||||||
|
* Caffeine 캐시(TTL 5분) 적용.
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public List<VesselAnalysisDto> getLatestResults() {
|
public List<VesselAnalysisDto> getLatestResults() {
|
||||||
@ -30,10 +34,16 @@ public class VesselAnalysisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 최근 1시간 이내 분석 결과 (Python 5분 주기 → 최대 12사이클 포함)
|
|
||||||
Instant since = Instant.now().minus(1, ChronoUnit.HOURS);
|
Instant since = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||||
List<VesselAnalysisDto> results = repository.findByAnalyzedAtAfter(since)
|
// mmsi별 최신 analyzed_at 1건만 유지
|
||||||
.stream()
|
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)
|
.map(VesselAnalysisDto::from)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types';
|
import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types';
|
||||||
import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
|
import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
|
||||||
|
import { fetchVesselTrack } from '../../services/vesselTrack';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
stats: AnalysisStats;
|
stats: AnalysisStats;
|
||||||
@ -9,6 +10,7 @@ interface Props {
|
|||||||
analysisMap: Map<string, VesselAnalysisDto>;
|
analysisMap: Map<string, VesselAnalysisDto>;
|
||||||
ships: Ship[];
|
ships: Ship[];
|
||||||
onShipSelect?: (mmsi: string) => void;
|
onShipSelect?: (mmsi: string) => void;
|
||||||
|
onTrackLoad?: (mmsi: string, coords: [number, number][]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VesselListItem {
|
interface VesselListItem {
|
||||||
@ -43,10 +45,36 @@ const RISK_EMOJI: Record<RiskLevel, string> = {
|
|||||||
|
|
||||||
const RISK_LEVELS: RiskLevel[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
|
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 [expanded, setExpanded] = useState(true);
|
||||||
const [selectedLevel, setSelectedLevel] = useState<RiskLevel | null>(null);
|
const [selectedLevel, setSelectedLevel] = useState<RiskLevel | null>(null);
|
||||||
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||||
|
const [showLegend, setShowLegend] = useState(false);
|
||||||
|
|
||||||
const isEmpty = useMemo(() => stats.total === 0, [stats.total]);
|
const isEmpty = useMemo(() => stats.total === 0, [stats.total]);
|
||||||
|
|
||||||
@ -66,9 +94,11 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
|||||||
setSelectedMmsi(null);
|
setSelectedMmsi(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVesselClick = (mmsi: string) => {
|
const handleVesselClick = async (mmsi: string) => {
|
||||||
setSelectedMmsi(prev => (prev === mmsi ? null : mmsi));
|
setSelectedMmsi(prev => (prev === mmsi ? null : mmsi));
|
||||||
onShipSelect?.(mmsi);
|
onShipSelect?.(mmsi);
|
||||||
|
const coords = await fetchVesselTrack(mmsi);
|
||||||
|
if (coords.length > 0) onTrackLoad?.(mmsi, coords);
|
||||||
};
|
};
|
||||||
|
|
||||||
const panelStyle: React.CSSProperties = {
|
const panelStyle: React.CSSProperties = {
|
||||||
@ -78,7 +108,6 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
|||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
maxWidth: 280,
|
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,
|
||||||
@ -147,6 +176,18 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
|||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const legendDividerStyle: React.CSSProperties = {
|
||||||
|
...dividerStyle,
|
||||||
|
marginTop: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const legendBodyStyle: React.CSSProperties = {
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#475569',
|
||||||
|
lineHeight: 1.7,
|
||||||
|
whiteSpace: 'pre',
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={panelStyle}>
|
<div style={panelStyle}>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
@ -157,8 +198,16 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
|||||||
<span style={{ fontSize: 9, color: '#fbbf24' }}>로딩중...</span>
|
<span style={{ fontSize: 9, color: '#fbbf24' }}>로딩중...</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<span style={{ fontSize: 9, color: '#64748b' }}>{formatTime(lastUpdated)}</span>
|
||||||
|
<button
|
||||||
|
style={toggleButtonStyle}
|
||||||
|
onClick={() => setShowLegend(prev => !prev)}
|
||||||
|
aria-label="범례 보기"
|
||||||
|
title="범례"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
style={toggleButtonStyle}
|
style={toggleButtonStyle}
|
||||||
onClick={() => setExpanded(prev => !prev)}
|
onClick={() => setExpanded(prev => !prev)}
|
||||||
@ -236,7 +285,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
|||||||
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 4 }}>
|
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 4 }}>
|
||||||
{RISK_EMOJI[selectedLevel]} {selectedLevel} — {vesselList.length}척
|
{RISK_EMOJI[selectedLevel]} {selectedLevel} — {vesselList.length}척
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
|
||||||
{vesselList.map(item => {
|
{vesselList.map(item => {
|
||||||
const isExpanded = selectedMmsi === item.mmsi;
|
const isExpanded = selectedMmsi === item.mmsi;
|
||||||
const color = RISK_COLOR[selectedLevel];
|
const color = RISK_COLOR[selectedLevel];
|
||||||
@ -245,7 +294,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
|||||||
<div key={item.mmsi}>
|
<div key={item.mmsi}>
|
||||||
{/* 선박 행 */}
|
{/* 선박 행 */}
|
||||||
<div
|
<div
|
||||||
onClick={() => handleVesselClick(item.mmsi)}
|
onClick={() => { void handleVesselClick(item.mmsi); }}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -271,7 +320,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
|||||||
{item.mmsi}
|
{item.mmsi}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color, fontWeight: 700, fontSize: 10, flexShrink: 0 }}>
|
<span style={{ color, fontWeight: 700, fontSize: 10, flexShrink: 0 }}>
|
||||||
{Math.round(item.score * 100)}
|
{Math.round(item.score * 100)}점
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0 }}>▶</span>
|
<span style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0 }}>▶</span>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -135,6 +135,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
||||||
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
|
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
|
||||||
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
|
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
|
||||||
|
const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchKoreaInfra().then(setInfra).catch(() => {});
|
fetchKoreaInfra().then(setInfra).catch(() => {});
|
||||||
@ -147,12 +148,20 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
}
|
}
|
||||||
}, [flyToTarget]);
|
}, [flyToTarget]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedAnalysisMmsi) setTrackCoords(null);
|
||||||
|
}, [selectedAnalysisMmsi]);
|
||||||
|
|
||||||
const handleAnalysisShipSelect = useCallback((mmsi: string) => {
|
const handleAnalysisShipSelect = useCallback((mmsi: string) => {
|
||||||
setSelectedAnalysisMmsi(mmsi);
|
setSelectedAnalysisMmsi(mmsi);
|
||||||
const ship = (allShips ?? ships).find(s => s.mmsi === mmsi);
|
const ship = (allShips ?? ships).find(s => s.mmsi === mmsi);
|
||||||
if (ship) setFlyToTarget({ lng: ship.lng, lat: ship.lat, zoom: 12 });
|
if (ship) setFlyToTarget({ lng: ship.lng, lat: ship.lat, zoom: 12 });
|
||||||
}, [allShips, ships]);
|
}, [allShips, ships]);
|
||||||
|
|
||||||
|
const handleTrackLoad = useCallback((_mmsi: string, coords: [number, number][]) => {
|
||||||
|
setTrackCoords(coords);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Map
|
<Map
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
@ -392,32 +401,26 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 선택된 분석 선박 항적 */}
|
{/* 선택된 분석 선박 항적 — tracks API 응답 기반 */}
|
||||||
{selectedAnalysisMmsi && (() => {
|
{trackCoords && trackCoords.length > 1 && (
|
||||||
const ship = (allShips ?? ships).find(s => s.mmsi === selectedAnalysisMmsi);
|
<Source id="analysis-trail" type="geojson" data={{
|
||||||
if (!ship?.trail || ship.trail.length < 2) return null;
|
type: 'FeatureCollection',
|
||||||
const trailGeoJson = {
|
|
||||||
type: 'FeatureCollection' as const,
|
|
||||||
features: [{
|
features: [{
|
||||||
type: 'Feature' as const,
|
type: 'Feature',
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: {
|
geometry: {
|
||||||
type: 'LineString' as const,
|
type: 'LineString',
|
||||||
coordinates: ship.trail.map(([lat, lng]) => [lng, lat]),
|
coordinates: trackCoords,
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
};
|
}}>
|
||||||
return (
|
<Layer id="analysis-trail-line" type="line" paint={{
|
||||||
<Source id="analysis-trail" type="geojson" data={trailGeoJson}>
|
'line-color': '#00e5ff',
|
||||||
<Layer id="analysis-trail-line" type="line" paint={{
|
'line-width': 2.5,
|
||||||
'line-color': '#00e5ff',
|
'line-opacity': 0.8,
|
||||||
'line-width': 2.5,
|
}} />
|
||||||
'line-opacity': 0.8,
|
</Source>
|
||||||
'line-dasharray': [2, 1],
|
)}
|
||||||
}} />
|
|
||||||
</Source>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* AI Analysis Stats Panel — 항상 표시 */}
|
{/* AI Analysis Stats Panel — 항상 표시 */}
|
||||||
{vesselAnalysis && (
|
{vesselAnalysis && (
|
||||||
@ -428,6 +431,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
analysisMap={vesselAnalysis.analysisMap}
|
analysisMap={vesselAnalysis.analysisMap}
|
||||||
ships={allShips ?? ships}
|
ships={allShips ?? ships}
|
||||||
onShipSelect={handleAnalysisShipSelect}
|
onShipSelect={handleAnalysisShipSelect}
|
||||||
|
onTrackLoad={handleTrackLoad}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Map>
|
</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