Merge pull request 'release: 2026-03-25.2 (5건 커밋)' (#201) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m18s
All checks were successful
Deploy KCG / deploy (push) Successful in 2m18s
This commit is contained in:
커밋
f0094c21d3
@ -4,6 +4,20 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-25.2]
|
||||
|
||||
### 추가
|
||||
- 현장분석 항적 미니맵: 선박 클릭 시 72시간 항적 + 현재 위치 표시
|
||||
- 현장분석 좌측 패널: 위험도 점수 기준 섹션 (AI분석 범례와 동일)
|
||||
- Python 경량 분석: 파이프라인 미통과 412* 선박에 위치/허가 기반 간이 위험도 생성
|
||||
|
||||
### 변경
|
||||
- 위험도 용어 통일: HIGH→WATCH, MEDIUM→MONITOR, LOW→NORMAL (AI분석/현장분석/보고서/deck.gl)
|
||||
- 공통 riskMapping.ts: 색상/이모지/레이블 매핑 상수 통합
|
||||
- 현장분석 fallback 제거: 클라이언트 수역판정/SOG규칙 → Python 분석 결과 전용
|
||||
- 보고서 위험 평가: 자체 규칙 등급 매기기 → Python riskCounts 실데이터 기반
|
||||
- 보고서 다크베셀/수역 분류: Python isDark/zone 기반으로 전환
|
||||
|
||||
## [2026-03-25.1]
|
||||
|
||||
### 변경
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types';
|
||||
import type { VesselAnalysisDto, Ship } from '../../types';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
import { fetchVesselTrack } from '../../services/vesselTrack';
|
||||
import {
|
||||
type AlertLevel,
|
||||
ALERT_COLOR,
|
||||
ALERT_EMOJI,
|
||||
ALERT_LEVELS,
|
||||
STATS_KEY_MAP,
|
||||
RISK_TO_ALERT,
|
||||
} from '../../constants/riskMapping';
|
||||
|
||||
interface Props {
|
||||
stats: AnalysisStats;
|
||||
@ -32,22 +40,6 @@ function formatTime(ms: number): string {
|
||||
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)',
|
||||
'',
|
||||
@ -65,8 +57,8 @@ const LEGEND_LINES = [
|
||||
'■ 허가 이력 (최대 20점)',
|
||||
' 미허가 어선: 20',
|
||||
'',
|
||||
'CRITICAL ≥70 / HIGH ≥50',
|
||||
'MEDIUM ≥30 / LOW <30',
|
||||
'CRITICAL ≥70 / WATCH ≥50',
|
||||
'MONITOR ≥30 / NORMAL <30',
|
||||
'',
|
||||
'UCAF: 어구별 조업속도 매칭 비율',
|
||||
'UCFT: 조업-항행 구분 신뢰도',
|
||||
@ -83,7 +75,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
||||
// 마운트 시 저장된 상태를 부모에 동기화
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { onExpandedChange?.(expanded); }, []);
|
||||
const [selectedLevel, setSelectedLevel] = useState<RiskLevel | null>(null);
|
||||
const [selectedLevel, setSelectedLevel] = useState<AlertLevel | null>(null);
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||
const [showLegend, setShowLegend] = useState(false);
|
||||
|
||||
@ -93,14 +85,14 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
||||
if (!selectedLevel) return [];
|
||||
const list: VesselListItem[] = [];
|
||||
for (const [mmsi, dto] of analysisMap) {
|
||||
if (dto.algorithms.riskScore.level !== selectedLevel) continue;
|
||||
if (RISK_TO_ALERT[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) => {
|
||||
const handleLevelClick = (level: AlertLevel) => {
|
||||
setSelectedLevel(prev => (prev === level ? null : level));
|
||||
setSelectedMmsi(null);
|
||||
};
|
||||
@ -272,8 +264,9 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
||||
|
||||
{/* 위험도 카운트 행 — 클릭 가능 */}
|
||||
<div style={riskRowStyle}>
|
||||
{RISK_LEVELS.map(level => {
|
||||
const count = stats[level.toLowerCase() as 'critical' | 'high' | 'medium' | 'low'];
|
||||
{ALERT_LEVELS.map(level => {
|
||||
const count = stats[STATS_KEY_MAP[level]];
|
||||
const color = ALERT_COLOR[level];
|
||||
const isActive = selectedLevel === level;
|
||||
return (
|
||||
<button
|
||||
@ -284,8 +277,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
background: isActive ? `${RISK_COLOR[level]}22` : 'none',
|
||||
border: isActive ? `1px solid ${RISK_COLOR[level]}88` : '1px solid transparent',
|
||||
background: isActive ? `${color}22` : 'none',
|
||||
border: isActive ? `1px solid ${color}88` : '1px solid transparent',
|
||||
borderRadius: 4,
|
||||
color: '#cbd5e1',
|
||||
fontSize: 10,
|
||||
@ -294,8 +287,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
||||
fontFamily: FONT_MONO,
|
||||
}}
|
||||
>
|
||||
<span>{RISK_EMOJI[level]}</span>
|
||||
<span style={{ color: RISK_COLOR[level], fontWeight: 700 }}>{count}</span>
|
||||
<span>{ALERT_EMOJI[level]}</span>
|
||||
<span style={{ color, fontWeight: 700 }}>{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@ -306,12 +299,12 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
||||
<>
|
||||
<div style={{ ...dividerStyle, marginTop: 8 }} />
|
||||
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 4 }}>
|
||||
{RISK_EMOJI[selectedLevel]} {selectedLevel} — {vesselList.length}척
|
||||
{ALERT_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 color = ALERT_COLOR[selectedLevel];
|
||||
const { dto } = item;
|
||||
return (
|
||||
<div key={item.mmsi}>
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto } from '../../types';
|
||||
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto, RiskLevel } from '../../types';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
import { classifyFishingZone } from '../../utils/fishingAnalysis';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { lookupPermittedShip } from '../../services/chnPrmShip';
|
||||
import { fetchVesselTrack } from '../../services/vesselTrack';
|
||||
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
|
||||
import { RISK_TO_ALERT } from '../../constants/riskMapping';
|
||||
import { Map as MapGL, Source, Layer, Marker } from 'react-map-gl/maplibre';
|
||||
|
||||
// MarineTraffic 사진 캐시 (null = 없음, undefined = 미조회)
|
||||
const mtPhotoCache = new Map<string, string | null>();
|
||||
@ -57,22 +59,17 @@ const C = {
|
||||
border2: '#0E2035',
|
||||
} as const;
|
||||
|
||||
// AIS 수신 기준 선박 상태 분류 (Python 결과 없을 때 fallback)
|
||||
function classifyStateFallback(ship: Ship): string {
|
||||
const ageMins = (Date.now() - ship.lastSeen) / 60000;
|
||||
if (ageMins > 20) return 'AIS_LOSS';
|
||||
if (ship.speed <= 0.5) return 'STATIONARY';
|
||||
if (ship.speed >= 5.0) return 'SAILING';
|
||||
return 'FISHING';
|
||||
}
|
||||
|
||||
// Python RiskLevel → 경보 등급 매핑
|
||||
function riskToAlert(level: string): 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL' {
|
||||
if (level === 'CRITICAL') return 'CRITICAL';
|
||||
if (level === 'HIGH') return 'WATCH';
|
||||
if (level === 'MEDIUM') return 'MONITOR';
|
||||
return 'NORMAL';
|
||||
}
|
||||
const MINIMAP_STYLE = {
|
||||
version: 8 as const,
|
||||
sources: {
|
||||
'carto-dark': {
|
||||
type: 'raster' as const,
|
||||
tiles: ['https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png'],
|
||||
tileSize: 256,
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'carto-dark', type: 'raster' as const, source: 'carto-dark' }],
|
||||
};
|
||||
|
||||
function stateLabel(s: string): string {
|
||||
const map: Record<string, string> = {
|
||||
@ -142,33 +139,18 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
|
||||
return cat === 'fishing' || s.category === 'fishing';
|
||||
}), [ships]);
|
||||
|
||||
// 선박 데이터 처리 — Python 분석 결과 우선, 없으면 GeoJSON 수역 + AIS fallback
|
||||
// 선박 데이터 처리 — Python 분석 결과 기반 (경량 분석 포함)
|
||||
const processed = useMemo((): ProcessedVessel[] => {
|
||||
return cnFishing.map(ship => {
|
||||
const dto = analysisMap.get(ship.mmsi);
|
||||
|
||||
// 수역: Python → GeoJSON 폴리곤 fallback
|
||||
let zone: string;
|
||||
if (dto) {
|
||||
zone = dto.algorithms.location.zone;
|
||||
} else {
|
||||
const zoneInfo = classifyFishingZone(ship.lat, ship.lng);
|
||||
zone = zoneInfo.zone === 'OUTSIDE' ? 'EEZ_OR_BEYOND' : zoneInfo.zone;
|
||||
}
|
||||
|
||||
// 행동 상태: Python → AIS fallback
|
||||
const state = dto?.algorithms.activity.state ?? classifyStateFallback(ship);
|
||||
|
||||
// 경보 등급: Python 위험도 직접 사용
|
||||
const alert = dto ? riskToAlert(dto.algorithms.riskScore.level) : 'NORMAL';
|
||||
|
||||
// 어구 분류: Python classification
|
||||
const vtype = dto?.classification.vesselType ?? 'UNKNOWN';
|
||||
|
||||
// 클러스터: Python cluster ID
|
||||
const clusterId = dto?.algorithms.cluster.clusterId ?? -1;
|
||||
return cnFishing
|
||||
.filter(ship => analysisMap.has(ship.mmsi))
|
||||
.map(ship => {
|
||||
const dto = analysisMap.get(ship.mmsi)!;
|
||||
const zone = dto.algorithms.location.zone;
|
||||
const state = dto.algorithms.activity.state;
|
||||
const alert = RISK_TO_ALERT[dto.algorithms.riskScore.level as RiskLevel];
|
||||
const vtype = dto.classification.vesselType ?? 'UNKNOWN';
|
||||
const clusterId = dto.algorithms.cluster.clusterId ?? -1;
|
||||
const cluster = clusterId >= 0 ? `C-${String(clusterId).padStart(2, '0')}` : '—';
|
||||
|
||||
return { ship, zone, state, alert, vtype, cluster };
|
||||
});
|
||||
}, [cnFishing, analysisMap]);
|
||||
@ -256,6 +238,14 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
|
||||
[selectedMmsi, processed],
|
||||
);
|
||||
|
||||
// 항적 미니맵
|
||||
const [trackCoords, setTrackCoords] = useState<[number, number][]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedVessel) { setTrackCoords([]); return; }
|
||||
fetchVesselTrack(selectedVessel.ship.mmsi, 72).then(setTrackCoords);
|
||||
}, [selectedVessel]);
|
||||
|
||||
// 허가 정보
|
||||
const [permitStatus, setPermitStatus] = useState<'idle' | 'loading' | 'found' | 'not-found'>('idle');
|
||||
const [permitData, setPermitData] = useState<ChnPrmShipInfo | null>(null);
|
||||
@ -506,6 +496,35 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
|
||||
<span style={{ fontSize: 9, color }}>{val}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 위험도 점수 기준 */}
|
||||
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
|
||||
위험도 점수 기준
|
||||
</div>
|
||||
<div style={{ fontSize: 9, color: C.ink3, lineHeight: 1.8 }}>
|
||||
{[
|
||||
{ title: '■ 위치 (최대 40점)', items: ['영해 내: 40 / 접속수역: 10'] },
|
||||
{ title: '■ 조업 행위 (최대 30점)', items: ['영해 내 조업: 20 / 기타 조업: 5', 'U-turn 패턴: 10'] },
|
||||
{ title: '■ AIS 조작 (최대 35점)', items: ['순간이동: 20 / 장시간 갭: 15', '단시간 갭: 5'] },
|
||||
{ title: '■ 허가 이력 (최대 20점)', items: ['미허가 어선: 20'] },
|
||||
].map(({ title, items }) => (
|
||||
<div key={title} style={{ marginBottom: 6 }}>
|
||||
<div style={{ color: C.ink2 }}>{title}</div>
|
||||
{items.map(item => <div key={item} style={{ paddingLeft: 8 }}>{item}</div>)}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginTop: 6, borderTop: `1px solid ${C.border}`, paddingTop: 6, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 8px' }}>
|
||||
<span style={{ color: C.red }}>CRITICAL ≥70</span>
|
||||
<span style={{ color: C.amber }}>WATCH ≥50</span>
|
||||
<span style={{ color: C.cyan }}>MONITOR ≥30</span>
|
||||
<span style={{ color: C.green }}>NORMAL {'<'}30</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: C.ink3 }}>
|
||||
UCAF: 어구별 조업속도 매칭 비율<br />
|
||||
UCFT: 조업-항행 구분 신뢰도<br />
|
||||
스푸핑: 순간이동+SOG급변+BD09 종합
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 중앙 패널: 선박 테이블 */}
|
||||
@ -840,6 +859,38 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 항적 미니맵 */}
|
||||
<div style={{ borderTop: `1px solid ${C.border}`, padding: '8px 12px 12px' }}>
|
||||
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, marginBottom: 6 }}>항적 미니맵</div>
|
||||
<div style={{ height: 180, borderRadius: 4, overflow: 'hidden', border: `1px solid ${C.border}` }}>
|
||||
<MapGL
|
||||
key={selectedVessel.ship.mmsi}
|
||||
initialViewState={{ longitude: selectedVessel.ship.lng, latitude: selectedVessel.ship.lat, zoom: 3 }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={MINIMAP_STYLE}
|
||||
attributionControl={false}
|
||||
interactive={false}
|
||||
>
|
||||
{trackCoords.length > 1 && (
|
||||
<Source id="minimap-track" type="geojson" data={{
|
||||
type: 'Feature', properties: {},
|
||||
geometry: { type: 'LineString', coordinates: trackCoords },
|
||||
}}>
|
||||
<Layer id="minimap-track-line" type="line" paint={{
|
||||
'line-color': '#fbbf24', 'line-width': 2, 'line-opacity': 0.8,
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
<Marker longitude={selectedVessel.ship.lng} latitude={selectedVessel.ship.lat}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: C.red, border: '2px solid #fff' }} />
|
||||
</Marker>
|
||||
</MapGL>
|
||||
</div>
|
||||
<div style={{ fontSize: 8, color: C.ink3, marginTop: 4 }}>
|
||||
최근 72시간 항적 · {trackCoords.length}포인트
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: '24px 0', textAlign: 'center', color: C.ink3, fontSize: 10 }}>
|
||||
|
||||
@ -334,7 +334,7 @@ export const KoreaDashboard = ({
|
||||
/>
|
||||
)}
|
||||
{showReport && (
|
||||
<ReportModal ships={koreaData.ships} onClose={() => setShowReport(false)} largestGearGroup={largestGearGroup} />
|
||||
<ReportModal ships={koreaData.ships} onClose={() => setShowReport(false)} largestGearGroup={largestGearGroup} analysisMap={vesselAnalysis.analysisMap} />
|
||||
)}
|
||||
{showOpsGuide && (
|
||||
<OpsGuideModal
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import type { Ship } from '../../types';
|
||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { aggregateFishingStats, GEAR_LABELS, classifyFishingZone, ZONE_ALLOWED } from '../../utils/fishingAnalysis';
|
||||
import { aggregateFishingStats, GEAR_LABELS, ZONE_ALLOWED } from '../../utils/fishingAnalysis';
|
||||
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
||||
import { ALERT_COLOR } from '../../constants/riskMapping';
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
onClose: () => void;
|
||||
largestGearGroup?: { name: string; count: number };
|
||||
analysisMap?: Map<string, VesselAnalysisDto>;
|
||||
}
|
||||
|
||||
const ALL_GEAR_TYPES = ['PT', 'OT', 'GN', 'PS', 'FC'];
|
||||
@ -44,7 +46,7 @@ function now() {
|
||||
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ReportModal({ ships, onClose, largestGearGroup }: Props) {
|
||||
export function ReportModal({ ships, onClose, largestGearGroup, analysisMap }: Props) {
|
||||
const reportRef = useRef<HTMLDivElement>(null);
|
||||
const timestamp = useMemo(() => now(), []);
|
||||
|
||||
@ -66,15 +68,25 @@ export function ReportModal({ ships, onClose, largestGearGroup }: Props) {
|
||||
// Gear analysis
|
||||
const fishingStats = aggregateFishingStats(cn);
|
||||
|
||||
// Zone analysis
|
||||
// Zone analysis — Python 분석 결과 기반 (현장분석과 동일 기준)
|
||||
const zoneStats: Record<string, number> = { ZONE_I: 0, ZONE_II: 0, ZONE_III: 0, ZONE_IV: 0, OUTSIDE: 0 };
|
||||
cnFishing.forEach(s => {
|
||||
const z = classifyFishingZone(s.lat, s.lng);
|
||||
zoneStats[z.zone] = (zoneStats[z.zone] || 0) + 1;
|
||||
const dto = analysisMap?.get(s.mmsi);
|
||||
if (dto) {
|
||||
const zone = dto.algorithms.location.zone;
|
||||
if (zone.startsWith('ZONE_') && zone in zoneStats) {
|
||||
zoneStats[zone] = (zoneStats[zone] || 0) + 1;
|
||||
} else {
|
||||
zoneStats.OUTSIDE = (zoneStats.OUTSIDE || 0) + 1;
|
||||
}
|
||||
}
|
||||
// dto 없는 선박은 수역 집계에서 제외 (미분석)
|
||||
});
|
||||
|
||||
// Dark vessels (AIS gap)
|
||||
const darkSuspect = cnFishing.filter(s => s.speed === 0 && (!s.heading || s.heading === 0));
|
||||
// Dark vessels — Python 분석 결과 기반 (현장분석과 동일 기준)
|
||||
const darkSuspect = cnFishing.filter(s =>
|
||||
analysisMap?.get(s.mmsi)?.algorithms.darkVessel.isDark === true,
|
||||
);
|
||||
|
||||
// Ship types
|
||||
const byType: Record<string, number> = {};
|
||||
@ -88,8 +100,30 @@ export function ReportModal({ ships, onClose, largestGearGroup }: Props) {
|
||||
ships.forEach(s => { byFlag[s.flag || 'unknown'] = (byFlag[s.flag || 'unknown'] || 0) + 1; });
|
||||
const topFlags = Object.entries(byFlag).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
||||
|
||||
return { total: ships.length, kr, cn, cnFishing, cnAnchored, cnLowSpeed, cnOperating, cnSailing, fishingStats, zoneStats, darkSuspect, byType, topFlags };
|
||||
}, [ships]);
|
||||
// Python 분석 결과 집계 (현장분석/AI분석과 동일 기준)
|
||||
let analysisTotal = 0;
|
||||
const riskCounts = { critical: 0, watch: 0, monitor: 0, normal: 0 };
|
||||
let spoofingCount = 0;
|
||||
if (analysisMap) {
|
||||
cnFishing.forEach(s => {
|
||||
const dto = analysisMap.get(s.mmsi);
|
||||
if (!dto) return;
|
||||
analysisTotal++;
|
||||
const level = dto.algorithms.riskScore.level;
|
||||
if (level === 'CRITICAL') riskCounts.critical++;
|
||||
else if (level === 'HIGH') riskCounts.watch++;
|
||||
else if (level === 'MEDIUM') riskCounts.monitor++;
|
||||
else riskCounts.normal++;
|
||||
if (dto.algorithms.gpsSpoofing.spoofingScore > 0.5) spoofingCount++;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
total: ships.length, kr, cn, cnFishing, cnAnchored, cnLowSpeed, cnOperating, cnSailing,
|
||||
fishingStats, zoneStats, darkSuspect, byType, topFlags,
|
||||
analysisTotal, riskCounts, spoofingCount,
|
||||
};
|
||||
}, [ships, analysisMap]);
|
||||
|
||||
const handlePrint = () => {
|
||||
const content = reportRef.current;
|
||||
@ -231,16 +265,21 @@ export function ReportModal({ ships, onClose, largestGearGroup }: Props) {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 5. 위험 분석 */}
|
||||
<h2 style={h2Style}>5. 위험 평가</h2>
|
||||
{/* 5. 위험 평가 — Python AI 분석 결과 기반 */}
|
||||
<h2 style={h2Style}>5. 위험 평가 (AI 분석)</h2>
|
||||
<table style={tableStyle}>
|
||||
<thead><tr style={{ background: '#1e293b' }}>
|
||||
<th style={thStyle}>위험 유형</th><th style={thStyle}>현재 상태</th><th style={thStyle}>등급</th>
|
||||
<th style={thStyle}>항목</th><th style={thStyle}>척수</th><th style={thStyle}>등급</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td style={tdStyle}>다크베셀 의심</td><td style={tdBold}>{stats.darkSuspect.length}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: stats.darkSuspect.length > 10 ? '#dc2626' : '#f59e0b' }}>{stats.darkSuspect.length > 10 ? 'HIGH' : 'MEDIUM'}</span></td></tr>
|
||||
<tr><td style={tdStyle}>수역 외 어선</td><td style={tdBold}>{stats.zoneStats.OUTSIDE}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: stats.zoneStats.OUTSIDE > 0 ? '#dc2626' : '#22c55e' }}>{stats.zoneStats.OUTSIDE > 0 ? 'CRITICAL' : 'NORMAL'}</span></td></tr>
|
||||
<tr><td style={tdStyle}>조업 중 어선</td><td style={tdBold}>{stats.cnOperating.length}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: '#3b82f6' }}>MONITOR</span></td></tr>
|
||||
<tr><td style={tdStyle}>총 분석 대상</td><td style={tdBold}>{stats.analysisTotal}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: '#475569' }}>—</span></td></tr>
|
||||
<tr style={{ background: 'rgba(255,82,82,0.08)' }}><td style={tdStyle}>CRITICAL (긴급)</td><td style={{ ...tdBold, color: ALERT_COLOR.CRITICAL }}>{stats.riskCounts.critical}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: ALERT_COLOR.CRITICAL }}>CRITICAL</span></td></tr>
|
||||
<tr style={{ background: 'rgba(255,215,64,0.06)' }}><td style={tdStyle}>WATCH (경고)</td><td style={{ ...tdBold, color: ALERT_COLOR.WATCH }}>{stats.riskCounts.watch}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: ALERT_COLOR.WATCH, color: '#000' }}>WATCH</span></td></tr>
|
||||
<tr><td style={tdStyle}>MONITOR (주의)</td><td style={tdBold}>{stats.riskCounts.monitor}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: ALERT_COLOR.MONITOR, color: '#000' }}>MONITOR</span></td></tr>
|
||||
<tr><td style={tdStyle}>NORMAL (정상)</td><td style={tdBold}>{stats.riskCounts.normal}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: ALERT_COLOR.NORMAL, color: '#000' }}>NORMAL</span></td></tr>
|
||||
<tr><td style={tdStyle} colSpan={3} /></tr>
|
||||
<tr><td style={tdStyle}>다크베셀 의심</td><td style={tdBold}>{stats.darkSuspect.length}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: stats.darkSuspect.length > 0 ? ALERT_COLOR.WATCH : ALERT_COLOR.NORMAL }}>{stats.darkSuspect.length > 0 ? 'WATCH' : 'NORMAL'}</span></td></tr>
|
||||
<tr><td style={tdStyle}>GPS 스푸핑 의심</td><td style={tdBold}>{stats.spoofingCount}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: stats.spoofingCount > 0 ? ALERT_COLOR.WATCH : ALERT_COLOR.NORMAL }}>{stats.spoofingCount > 0 ? 'WATCH' : 'NORMAL'}</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
35
frontend/src/constants/riskMapping.ts
Normal file
35
frontend/src/constants/riskMapping.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { RiskLevel } from '../types';
|
||||
import type { AnalysisStats } from '../services/vesselAnalysis';
|
||||
|
||||
export type AlertLevel = 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL';
|
||||
|
||||
export const RISK_TO_ALERT: Record<RiskLevel, AlertLevel> = {
|
||||
CRITICAL: 'CRITICAL',
|
||||
HIGH: 'WATCH',
|
||||
MEDIUM: 'MONITOR',
|
||||
LOW: 'NORMAL',
|
||||
};
|
||||
|
||||
export const ALERT_COLOR: Record<AlertLevel, string> = {
|
||||
CRITICAL: '#FF5252',
|
||||
WATCH: '#FFD740',
|
||||
MONITOR: '#18FFFF',
|
||||
NORMAL: '#00E676',
|
||||
};
|
||||
|
||||
export const ALERT_EMOJI: Record<AlertLevel, string> = {
|
||||
CRITICAL: '🔴',
|
||||
WATCH: '🟠',
|
||||
MONITOR: '🟡',
|
||||
NORMAL: '🟢',
|
||||
};
|
||||
|
||||
export const ALERT_LEVELS: AlertLevel[] = ['CRITICAL', 'WATCH', 'MONITOR', 'NORMAL'];
|
||||
|
||||
// 서버 stats 키(critical/high/medium/low) → AlertLevel 매핑
|
||||
export const STATS_KEY_MAP: Record<AlertLevel, keyof Pick<AnalysisStats, 'critical' | 'high' | 'medium' | 'low'>> = {
|
||||
CRITICAL: 'critical',
|
||||
WATCH: 'high',
|
||||
MONITOR: 'medium',
|
||||
NORMAL: 'low',
|
||||
};
|
||||
@ -1,46 +1,51 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import type { Ship, VesselAnalysisDto } from '../types';
|
||||
import type { Ship, VesselAnalysisDto, RiskLevel } from '../types';
|
||||
import { useFontScale } from './useFontScale';
|
||||
import { FONT_MONO } from '../styles/fonts';
|
||||
import { RISK_TO_ALERT, type AlertLevel } from '../constants/riskMapping';
|
||||
|
||||
interface AnalyzedShip {
|
||||
ship: Ship;
|
||||
dto: VesselAnalysisDto;
|
||||
alert: AlertLevel;
|
||||
}
|
||||
|
||||
// RISK_RGBA: [r, g, b, a] 충전색
|
||||
const RISK_RGBA: Record<string, [number, number, number, number]> = {
|
||||
CRITICAL: [239, 68, 68, 60],
|
||||
HIGH: [249, 115, 22, 50],
|
||||
MEDIUM: [234, 179, 8, 40],
|
||||
// AlertLevel 기반 충전색 (현장분석 팔레트 통일)
|
||||
const ALERT_RGBA: Record<AlertLevel, [number, number, number, number]> = {
|
||||
CRITICAL: [255, 82, 82, 60],
|
||||
WATCH: [255, 215, 64, 50],
|
||||
MONITOR: [24, 255, 255, 40],
|
||||
NORMAL: [0, 230, 118, 30],
|
||||
};
|
||||
|
||||
// 테두리색
|
||||
const RISK_RGBA_BORDER: Record<string, [number, number, number, number]> = {
|
||||
CRITICAL: [239, 68, 68, 230],
|
||||
HIGH: [249, 115, 22, 210],
|
||||
MEDIUM: [234, 179, 8, 190],
|
||||
const ALERT_RGBA_BORDER: Record<AlertLevel, [number, number, number, number]> = {
|
||||
CRITICAL: [255, 82, 82, 230],
|
||||
WATCH: [255, 215, 64, 210],
|
||||
MONITOR: [24, 255, 255, 190],
|
||||
NORMAL: [0, 230, 118, 160],
|
||||
};
|
||||
|
||||
// 픽셀 반경
|
||||
const RISK_SIZE: Record<string, number> = {
|
||||
const ALERT_SIZE: Record<AlertLevel, number> = {
|
||||
CRITICAL: 18,
|
||||
HIGH: 14,
|
||||
MEDIUM: 12,
|
||||
WATCH: 14,
|
||||
MONITOR: 12,
|
||||
NORMAL: 10,
|
||||
};
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
const ALERT_LABEL: Record<AlertLevel, string> = {
|
||||
CRITICAL: '긴급',
|
||||
HIGH: '경고',
|
||||
MEDIUM: '주의',
|
||||
WATCH: '경고',
|
||||
MONITOR: '주의',
|
||||
NORMAL: '정상',
|
||||
};
|
||||
|
||||
const RISK_PRIORITY: Record<string, number> = {
|
||||
const ALERT_PRIORITY: Record<AlertLevel, number> = {
|
||||
CRITICAL: 0,
|
||||
HIGH: 1,
|
||||
MEDIUM: 2,
|
||||
WATCH: 1,
|
||||
MONITOR: 2,
|
||||
NORMAL: 3,
|
||||
};
|
||||
|
||||
interface AnalysisData {
|
||||
@ -69,18 +74,14 @@ export function useAnalysisDeckLayers(
|
||||
|
||||
const analyzedShips: AnalyzedShip[] = ships
|
||||
.filter(s => analysisMap.has(s.mmsi))
|
||||
.map(s => ({ ship: s, dto: analysisMap.get(s.mmsi)! }));
|
||||
.map(s => {
|
||||
const dto = analysisMap.get(s.mmsi)!;
|
||||
return { ship: s, dto, alert: RISK_TO_ALERT[dto.algorithms.riskScore.level as RiskLevel] };
|
||||
});
|
||||
|
||||
const riskData = analyzedShips
|
||||
.filter(({ dto }) => {
|
||||
const level = dto.algorithms.riskScore.level;
|
||||
return level === 'CRITICAL' || level === 'HIGH' || level === 'MEDIUM';
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const pa = RISK_PRIORITY[a.dto.algorithms.riskScore.level] ?? 99;
|
||||
const pb = RISK_PRIORITY[b.dto.algorithms.riskScore.level] ?? 99;
|
||||
return pa - pb;
|
||||
})
|
||||
.filter(({ alert }) => alert !== 'NORMAL')
|
||||
.sort((a, b) => ALERT_PRIORITY[a.alert] - ALERT_PRIORITY[b.alert])
|
||||
.slice(0, 100);
|
||||
|
||||
const darkData = analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark);
|
||||
@ -104,9 +105,9 @@ export function useAnalysisDeckLayers(
|
||||
id: 'risk-markers',
|
||||
data: riskData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getRadius: (d) => (RISK_SIZE[d.dto.algorithms.riskScore.level] ?? 12) * sizeScale,
|
||||
getFillColor: (d) => RISK_RGBA[d.dto.algorithms.riskScore.level] ?? [100, 100, 100, 40],
|
||||
getLineColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [100, 100, 100, 200],
|
||||
getRadius: (d) => (ALERT_SIZE[d.alert] ?? 12) * sizeScale,
|
||||
getFillColor: (d) => ALERT_RGBA[d.alert] ?? [100, 100, 100, 40],
|
||||
getLineColor: (d) => ALERT_RGBA_BORDER[d.alert] ?? [100, 100, 100, 200],
|
||||
stroked: true,
|
||||
filled: true,
|
||||
radiusUnits: 'pixels',
|
||||
@ -123,13 +124,13 @@ export function useAnalysisDeckLayers(
|
||||
data: riskData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getText: (d) => {
|
||||
const label = RISK_LABEL[d.dto.algorithms.riskScore.level] ?? d.dto.algorithms.riskScore.level;
|
||||
const label = ALERT_LABEL[d.alert] ?? d.alert;
|
||||
const name = d.ship.name || d.ship.mmsi;
|
||||
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
|
||||
},
|
||||
getSize: 10 * sizeScale * afs,
|
||||
updateTriggers: { getSize: [sizeScale] },
|
||||
getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255],
|
||||
getColor: (d) => ALERT_RGBA_BORDER[d.alert] ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 16],
|
||||
|
||||
@ -7,6 +7,46 @@ from algorithms.dark_vessel import detect_ais_gaps
|
||||
from algorithms.spoofing import detect_teleportation
|
||||
|
||||
|
||||
def compute_lightweight_risk_score(
|
||||
zone_info: dict,
|
||||
sog: float,
|
||||
is_permitted: Optional[bool] = None,
|
||||
) -> Tuple[int, str]:
|
||||
"""위치·허가 이력 기반 경량 위험도 (파이프라인 미통과 선박용).
|
||||
|
||||
compute_vessel_risk_score의 1번(위치)+4번(허가) 로직과 동일.
|
||||
Returns: (risk_score, risk_level)
|
||||
"""
|
||||
score = 0
|
||||
|
||||
# 1. 위치 기반 (최대 40점)
|
||||
zone = zone_info.get('zone', '')
|
||||
if zone == 'TERRITORIAL_SEA':
|
||||
score += 40
|
||||
elif zone == 'CONTIGUOUS_ZONE':
|
||||
score += 10
|
||||
elif zone.startswith('ZONE_'):
|
||||
if is_permitted is not None and not is_permitted:
|
||||
score += 25
|
||||
|
||||
# 4. 허가 이력 (최대 20점)
|
||||
if is_permitted is not None and not is_permitted:
|
||||
score += 20
|
||||
|
||||
score = min(score, 100)
|
||||
|
||||
if score >= 70:
|
||||
level = 'CRITICAL'
|
||||
elif score >= 50:
|
||||
level = 'HIGH'
|
||||
elif score >= 30:
|
||||
level = 'MEDIUM'
|
||||
else:
|
||||
level = 'LOW'
|
||||
|
||||
return score, level
|
||||
|
||||
|
||||
def compute_vessel_risk_score(
|
||||
mmsi: str,
|
||||
df_vessel: pd.DataFrame,
|
||||
|
||||
4
prediction/cache/vessel_store.py
vendored
4
prediction/cache/vessel_store.py
vendored
@ -349,6 +349,10 @@ class VesselStore:
|
||||
}
|
||||
return result
|
||||
|
||||
def get_chinese_mmsis(self) -> set:
|
||||
"""Return the set of all Chinese vessel MMSIs (412*) currently in the store."""
|
||||
return {m for m in self._tracks if m.startswith(_CHINESE_MMSI_PREFIX)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Properties
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@ -177,6 +177,68 @@ def run_analysis_cycle():
|
||||
features=c.get('features', {}),
|
||||
))
|
||||
|
||||
# ── 5.5 경량 분석 — 파이프라인 미통과 412* 선박 ──
|
||||
from algorithms.risk import compute_lightweight_risk_score
|
||||
|
||||
pipeline_mmsis = {c['mmsi'] for c in classifications}
|
||||
lightweight_mmsis = vessel_store.get_chinese_mmsis() - pipeline_mmsis
|
||||
|
||||
if lightweight_mmsis:
|
||||
now = datetime.now(timezone.utc)
|
||||
all_positions = vessel_store.get_all_latest_positions()
|
||||
lw_count = 0
|
||||
for mmsi in lightweight_mmsis:
|
||||
pos = all_positions.get(mmsi)
|
||||
if pos is None or pos.get('lat') is None:
|
||||
continue
|
||||
lat, lon = pos['lat'], pos['lon']
|
||||
sog = pos.get('sog', 0) or 0
|
||||
cog = pos.get('cog', 0) or 0
|
||||
ts = pos.get('timestamp', now)
|
||||
|
||||
zone_info = classify_zone(lat, lon)
|
||||
if sog <= 1.0:
|
||||
state = 'STATIONARY'
|
||||
elif sog <= 5.0:
|
||||
state = 'FISHING'
|
||||
else:
|
||||
state = 'SAILING'
|
||||
|
||||
is_permitted = vessel_store.is_permitted(mmsi)
|
||||
risk_score, risk_level = compute_lightweight_risk_score(
|
||||
zone_info, sog, is_permitted=is_permitted,
|
||||
)
|
||||
|
||||
# BD-09 오프셋은 중국 선박이므로 제외 (412* = 중국)
|
||||
results.append(AnalysisResult(
|
||||
mmsi=mmsi,
|
||||
timestamp=ts,
|
||||
vessel_type='UNKNOWN',
|
||||
confidence=0.0,
|
||||
fishing_pct=0.0,
|
||||
zone=zone_info.get('zone', 'EEZ_OR_BEYOND'),
|
||||
dist_to_baseline_nm=zone_info.get('dist_from_baseline_nm', 999.0),
|
||||
activity_state=state,
|
||||
ucaf_score=0.0,
|
||||
ucft_score=0.0,
|
||||
is_dark=False,
|
||||
gap_duration_min=0,
|
||||
spoofing_score=0.0,
|
||||
bd09_offset_m=0.0,
|
||||
speed_jump_count=0,
|
||||
cluster_id=-1,
|
||||
cluster_size=0,
|
||||
is_leader=False,
|
||||
fleet_role='NONE',
|
||||
risk_score=risk_score,
|
||||
risk_level=risk_level,
|
||||
is_transship_suspect=False,
|
||||
transship_pair_mmsi='',
|
||||
transship_duration_min=0,
|
||||
))
|
||||
lw_count += 1
|
||||
logger.info('lightweight analysis: %d vessels', lw_count)
|
||||
|
||||
# 6. 환적 의심 탐지 (pair_history 모듈 레벨로 사이클 간 유지)
|
||||
from algorithms.transshipment import detect_transshipment
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user