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]
|
## [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]
|
## [2026-03-25.1]
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
|
|||||||
@ -1,9 +1,17 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
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 { FONT_MONO } from '../../styles/fonts';
|
||||||
import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
|
import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
|
||||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||||
import { fetchVesselTrack } from '../../services/vesselTrack';
|
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 {
|
interface Props {
|
||||||
stats: AnalysisStats;
|
stats: AnalysisStats;
|
||||||
@ -32,22 +40,6 @@ function formatTime(ms: number): string {
|
|||||||
return `${hh}:${mm}`;
|
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 = [
|
const LEGEND_LINES = [
|
||||||
'위험도 점수 기준 (0~100)',
|
'위험도 점수 기준 (0~100)',
|
||||||
'',
|
'',
|
||||||
@ -65,8 +57,8 @@ const LEGEND_LINES = [
|
|||||||
'■ 허가 이력 (최대 20점)',
|
'■ 허가 이력 (최대 20점)',
|
||||||
' 미허가 어선: 20',
|
' 미허가 어선: 20',
|
||||||
'',
|
'',
|
||||||
'CRITICAL ≥70 / HIGH ≥50',
|
'CRITICAL ≥70 / WATCH ≥50',
|
||||||
'MEDIUM ≥30 / LOW <30',
|
'MONITOR ≥30 / NORMAL <30',
|
||||||
'',
|
'',
|
||||||
'UCAF: 어구별 조업속도 매칭 비율',
|
'UCAF: 어구별 조업속도 매칭 비율',
|
||||||
'UCFT: 조업-항행 구분 신뢰도',
|
'UCFT: 조업-항행 구분 신뢰도',
|
||||||
@ -83,7 +75,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
|||||||
// 마운트 시 저장된 상태를 부모에 동기화
|
// 마운트 시 저장된 상태를 부모에 동기화
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
useEffect(() => { onExpandedChange?.(expanded); }, []);
|
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 [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||||
const [showLegend, setShowLegend] = useState(false);
|
const [showLegend, setShowLegend] = useState(false);
|
||||||
|
|
||||||
@ -93,14 +85,14 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
|||||||
if (!selectedLevel) return [];
|
if (!selectedLevel) return [];
|
||||||
const list: VesselListItem[] = [];
|
const list: VesselListItem[] = [];
|
||||||
for (const [mmsi, dto] of analysisMap) {
|
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);
|
const ship = ships.find(s => s.mmsi === mmsi);
|
||||||
list.push({ mmsi, name: ship?.name || mmsi, score: dto.algorithms.riskScore.score, dto });
|
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);
|
return list.sort((a, b) => b.score - a.score).slice(0, 50);
|
||||||
}, [selectedLevel, analysisMap, ships]);
|
}, [selectedLevel, analysisMap, ships]);
|
||||||
|
|
||||||
const handleLevelClick = (level: RiskLevel) => {
|
const handleLevelClick = (level: AlertLevel) => {
|
||||||
setSelectedLevel(prev => (prev === level ? null : level));
|
setSelectedLevel(prev => (prev === level ? null : level));
|
||||||
setSelectedMmsi(null);
|
setSelectedMmsi(null);
|
||||||
};
|
};
|
||||||
@ -272,8 +264,9 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
|||||||
|
|
||||||
{/* 위험도 카운트 행 — 클릭 가능 */}
|
{/* 위험도 카운트 행 — 클릭 가능 */}
|
||||||
<div style={riskRowStyle}>
|
<div style={riskRowStyle}>
|
||||||
{RISK_LEVELS.map(level => {
|
{ALERT_LEVELS.map(level => {
|
||||||
const count = stats[level.toLowerCase() as 'critical' | 'high' | 'medium' | 'low'];
|
const count = stats[STATS_KEY_MAP[level]];
|
||||||
|
const color = ALERT_COLOR[level];
|
||||||
const isActive = selectedLevel === level;
|
const isActive = selectedLevel === level;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -284,8 +277,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 2,
|
gap: 2,
|
||||||
background: isActive ? `${RISK_COLOR[level]}22` : 'none',
|
background: isActive ? `${color}22` : 'none',
|
||||||
border: isActive ? `1px solid ${RISK_COLOR[level]}88` : '1px solid transparent',
|
border: isActive ? `1px solid ${color}88` : '1px solid transparent',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
color: '#cbd5e1',
|
color: '#cbd5e1',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@ -294,8 +287,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
|||||||
fontFamily: FONT_MONO,
|
fontFamily: FONT_MONO,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{RISK_EMOJI[level]}</span>
|
<span>{ALERT_EMOJI[level]}</span>
|
||||||
<span style={{ color: RISK_COLOR[level], fontWeight: 700 }}>{count}</span>
|
<span style={{ color, fontWeight: 700 }}>{count}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -306,12 +299,12 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
|||||||
<>
|
<>
|
||||||
<div style={{ ...dividerStyle, marginTop: 8 }} />
|
<div style={{ ...dividerStyle, marginTop: 8 }} />
|
||||||
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 4 }}>
|
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 4 }}>
|
||||||
{RISK_EMOJI[selectedLevel]} {selectedLevel} — {vesselList.length}척
|
{ALERT_EMOJI[selectedLevel]} {selectedLevel} — {vesselList.length}척
|
||||||
</div>
|
</div>
|
||||||
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
|
<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 = ALERT_COLOR[selectedLevel];
|
||||||
const { dto } = item;
|
const { dto } = item;
|
||||||
return (
|
return (
|
||||||
<div key={item.mmsi}>
|
<div key={item.mmsi}>
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
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 { FONT_MONO } from '../../styles/fonts';
|
||||||
import { classifyFishingZone } from '../../utils/fishingAnalysis';
|
|
||||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||||
import { lookupPermittedShip } from '../../services/chnPrmShip';
|
import { lookupPermittedShip } from '../../services/chnPrmShip';
|
||||||
|
import { fetchVesselTrack } from '../../services/vesselTrack';
|
||||||
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
|
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 = 미조회)
|
// MarineTraffic 사진 캐시 (null = 없음, undefined = 미조회)
|
||||||
const mtPhotoCache = new Map<string, string | null>();
|
const mtPhotoCache = new Map<string, string | null>();
|
||||||
@ -57,22 +59,17 @@ const C = {
|
|||||||
border2: '#0E2035',
|
border2: '#0E2035',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// AIS 수신 기준 선박 상태 분류 (Python 결과 없을 때 fallback)
|
const MINIMAP_STYLE = {
|
||||||
function classifyStateFallback(ship: Ship): string {
|
version: 8 as const,
|
||||||
const ageMins = (Date.now() - ship.lastSeen) / 60000;
|
sources: {
|
||||||
if (ageMins > 20) return 'AIS_LOSS';
|
'carto-dark': {
|
||||||
if (ship.speed <= 0.5) return 'STATIONARY';
|
type: 'raster' as const,
|
||||||
if (ship.speed >= 5.0) return 'SAILING';
|
tiles: ['https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png'],
|
||||||
return 'FISHING';
|
tileSize: 256,
|
||||||
}
|
},
|
||||||
|
},
|
||||||
// Python RiskLevel → 경보 등급 매핑
|
layers: [{ id: 'carto-dark', type: 'raster' as const, source: 'carto-dark' }],
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
function stateLabel(s: string): string {
|
function stateLabel(s: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
@ -142,33 +139,18 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
|
|||||||
return cat === 'fishing' || s.category === 'fishing';
|
return cat === 'fishing' || s.category === 'fishing';
|
||||||
}), [ships]);
|
}), [ships]);
|
||||||
|
|
||||||
// 선박 데이터 처리 — Python 분석 결과 우선, 없으면 GeoJSON 수역 + AIS fallback
|
// 선박 데이터 처리 — Python 분석 결과 기반 (경량 분석 포함)
|
||||||
const processed = useMemo((): ProcessedVessel[] => {
|
const processed = useMemo((): ProcessedVessel[] => {
|
||||||
return cnFishing.map(ship => {
|
return cnFishing
|
||||||
const dto = analysisMap.get(ship.mmsi);
|
.filter(ship => analysisMap.has(ship.mmsi))
|
||||||
|
.map(ship => {
|
||||||
// 수역: Python → GeoJSON 폴리곤 fallback
|
const dto = analysisMap.get(ship.mmsi)!;
|
||||||
let zone: string;
|
const zone = dto.algorithms.location.zone;
|
||||||
if (dto) {
|
const state = dto.algorithms.activity.state;
|
||||||
zone = dto.algorithms.location.zone;
|
const alert = RISK_TO_ALERT[dto.algorithms.riskScore.level as RiskLevel];
|
||||||
} else {
|
const vtype = dto.classification.vesselType ?? 'UNKNOWN';
|
||||||
const zoneInfo = classifyFishingZone(ship.lat, ship.lng);
|
const clusterId = dto.algorithms.cluster.clusterId ?? -1;
|
||||||
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;
|
|
||||||
const cluster = clusterId >= 0 ? `C-${String(clusterId).padStart(2, '0')}` : '—';
|
const cluster = clusterId >= 0 ? `C-${String(clusterId).padStart(2, '0')}` : '—';
|
||||||
|
|
||||||
return { ship, zone, state, alert, vtype, cluster };
|
return { ship, zone, state, alert, vtype, cluster };
|
||||||
});
|
});
|
||||||
}, [cnFishing, analysisMap]);
|
}, [cnFishing, analysisMap]);
|
||||||
@ -256,6 +238,14 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
|
|||||||
[selectedMmsi, processed],
|
[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 [permitStatus, setPermitStatus] = useState<'idle' | 'loading' | 'found' | 'not-found'>('idle');
|
||||||
const [permitData, setPermitData] = useState<ChnPrmShipInfo | null>(null);
|
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>
|
<span style={{ fontSize: 9, color }}>{val}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* ── 중앙 패널: 선박 테이블 */}
|
{/* ── 중앙 패널: 선박 테이블 */}
|
||||||
@ -840,6 +859,38 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 }}>
|
<div style={{ padding: '24px 0', textAlign: 'center', color: C.ink3, fontSize: 10 }}>
|
||||||
|
|||||||
@ -334,7 +334,7 @@ export const KoreaDashboard = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showReport && (
|
{showReport && (
|
||||||
<ReportModal ships={koreaData.ships} onClose={() => setShowReport(false)} largestGearGroup={largestGearGroup} />
|
<ReportModal ships={koreaData.ships} onClose={() => setShowReport(false)} largestGearGroup={largestGearGroup} analysisMap={vesselAnalysis.analysisMap} />
|
||||||
)}
|
)}
|
||||||
{showOpsGuide && (
|
{showOpsGuide && (
|
||||||
<OpsGuideModal
|
<OpsGuideModal
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import type { Ship } from '../../types';
|
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
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 type { FishingGearType } from '../../utils/fishingAnalysis';
|
||||||
|
import { ALERT_COLOR } from '../../constants/riskMapping';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ships: Ship[];
|
ships: Ship[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
largestGearGroup?: { name: string; count: number };
|
largestGearGroup?: { name: string; count: number };
|
||||||
|
analysisMap?: Map<string, VesselAnalysisDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_GEAR_TYPES = ['PT', 'OT', 'GN', 'PS', 'FC'];
|
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')}`;
|
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 reportRef = useRef<HTMLDivElement>(null);
|
||||||
const timestamp = useMemo(() => now(), []);
|
const timestamp = useMemo(() => now(), []);
|
||||||
|
|
||||||
@ -66,15 +68,25 @@ export function ReportModal({ ships, onClose, largestGearGroup }: Props) {
|
|||||||
// Gear analysis
|
// Gear analysis
|
||||||
const fishingStats = aggregateFishingStats(cn);
|
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 };
|
const zoneStats: Record<string, number> = { ZONE_I: 0, ZONE_II: 0, ZONE_III: 0, ZONE_IV: 0, OUTSIDE: 0 };
|
||||||
cnFishing.forEach(s => {
|
cnFishing.forEach(s => {
|
||||||
const z = classifyFishingZone(s.lat, s.lng);
|
const dto = analysisMap?.get(s.mmsi);
|
||||||
zoneStats[z.zone] = (zoneStats[z.zone] || 0) + 1;
|
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)
|
// Dark vessels — Python 분석 결과 기반 (현장분석과 동일 기준)
|
||||||
const darkSuspect = cnFishing.filter(s => s.speed === 0 && (!s.heading || s.heading === 0));
|
const darkSuspect = cnFishing.filter(s =>
|
||||||
|
analysisMap?.get(s.mmsi)?.algorithms.darkVessel.isDark === true,
|
||||||
|
);
|
||||||
|
|
||||||
// Ship types
|
// Ship types
|
||||||
const byType: Record<string, number> = {};
|
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; });
|
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);
|
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 };
|
// Python 분석 결과 집계 (현장분석/AI분석과 동일 기준)
|
||||||
}, [ships]);
|
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 handlePrint = () => {
|
||||||
const content = reportRef.current;
|
const content = reportRef.current;
|
||||||
@ -231,16 +265,21 @@ export function ReportModal({ ships, onClose, largestGearGroup }: Props) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{/* 5. 위험 분석 */}
|
{/* 5. 위험 평가 — Python AI 분석 결과 기반 */}
|
||||||
<h2 style={h2Style}>5. 위험 평가</h2>
|
<h2 style={h2Style}>5. 위험 평가 (AI 분석)</h2>
|
||||||
<table style={tableStyle}>
|
<table style={tableStyle}>
|
||||||
<thead><tr style={{ background: '#1e293b' }}>
|
<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>
|
</tr></thead>
|
||||||
<tbody>
|
<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.analysisTotal}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: '#475569' }}>—</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 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><td style={tdStyle}>조업 중 어선</td><td style={tdBold}>{stats.cnOperating.length}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: '#3b82f6' }}>MONITOR</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>
|
</tbody>
|
||||||
</table>
|
</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 { useMemo } from 'react';
|
||||||
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||||
import type { Layer } from '@deck.gl/core';
|
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 { useFontScale } from './useFontScale';
|
||||||
import { FONT_MONO } from '../styles/fonts';
|
import { FONT_MONO } from '../styles/fonts';
|
||||||
|
import { RISK_TO_ALERT, type AlertLevel } from '../constants/riskMapping';
|
||||||
|
|
||||||
interface AnalyzedShip {
|
interface AnalyzedShip {
|
||||||
ship: Ship;
|
ship: Ship;
|
||||||
dto: VesselAnalysisDto;
|
dto: VesselAnalysisDto;
|
||||||
|
alert: AlertLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// RISK_RGBA: [r, g, b, a] 충전색
|
// AlertLevel 기반 충전색 (현장분석 팔레트 통일)
|
||||||
const RISK_RGBA: Record<string, [number, number, number, number]> = {
|
const ALERT_RGBA: Record<AlertLevel, [number, number, number, number]> = {
|
||||||
CRITICAL: [239, 68, 68, 60],
|
CRITICAL: [255, 82, 82, 60],
|
||||||
HIGH: [249, 115, 22, 50],
|
WATCH: [255, 215, 64, 50],
|
||||||
MEDIUM: [234, 179, 8, 40],
|
MONITOR: [24, 255, 255, 40],
|
||||||
|
NORMAL: [0, 230, 118, 30],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 테두리색
|
const ALERT_RGBA_BORDER: Record<AlertLevel, [number, number, number, number]> = {
|
||||||
const RISK_RGBA_BORDER: Record<string, [number, number, number, number]> = {
|
CRITICAL: [255, 82, 82, 230],
|
||||||
CRITICAL: [239, 68, 68, 230],
|
WATCH: [255, 215, 64, 210],
|
||||||
HIGH: [249, 115, 22, 210],
|
MONITOR: [24, 255, 255, 190],
|
||||||
MEDIUM: [234, 179, 8, 190],
|
NORMAL: [0, 230, 118, 160],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 픽셀 반경
|
const ALERT_SIZE: Record<AlertLevel, number> = {
|
||||||
const RISK_SIZE: Record<string, number> = {
|
|
||||||
CRITICAL: 18,
|
CRITICAL: 18,
|
||||||
HIGH: 14,
|
WATCH: 14,
|
||||||
MEDIUM: 12,
|
MONITOR: 12,
|
||||||
|
NORMAL: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
const RISK_LABEL: Record<string, string> = {
|
const ALERT_LABEL: Record<AlertLevel, string> = {
|
||||||
CRITICAL: '긴급',
|
CRITICAL: '긴급',
|
||||||
HIGH: '경고',
|
WATCH: '경고',
|
||||||
MEDIUM: '주의',
|
MONITOR: '주의',
|
||||||
|
NORMAL: '정상',
|
||||||
};
|
};
|
||||||
|
|
||||||
const RISK_PRIORITY: Record<string, number> = {
|
const ALERT_PRIORITY: Record<AlertLevel, number> = {
|
||||||
CRITICAL: 0,
|
CRITICAL: 0,
|
||||||
HIGH: 1,
|
WATCH: 1,
|
||||||
MEDIUM: 2,
|
MONITOR: 2,
|
||||||
|
NORMAL: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AnalysisData {
|
interface AnalysisData {
|
||||||
@ -69,18 +74,14 @@ export function useAnalysisDeckLayers(
|
|||||||
|
|
||||||
const analyzedShips: AnalyzedShip[] = ships
|
const analyzedShips: AnalyzedShip[] = ships
|
||||||
.filter(s => analysisMap.has(s.mmsi))
|
.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
|
const riskData = analyzedShips
|
||||||
.filter(({ dto }) => {
|
.filter(({ alert }) => alert !== 'NORMAL')
|
||||||
const level = dto.algorithms.riskScore.level;
|
.sort((a, b) => ALERT_PRIORITY[a.alert] - ALERT_PRIORITY[b.alert])
|
||||||
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;
|
|
||||||
})
|
|
||||||
.slice(0, 100);
|
.slice(0, 100);
|
||||||
|
|
||||||
const darkData = analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark);
|
const darkData = analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark);
|
||||||
@ -104,9 +105,9 @@ export function useAnalysisDeckLayers(
|
|||||||
id: 'risk-markers',
|
id: 'risk-markers',
|
||||||
data: riskData,
|
data: riskData,
|
||||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||||
getRadius: (d) => (RISK_SIZE[d.dto.algorithms.riskScore.level] ?? 12) * sizeScale,
|
getRadius: (d) => (ALERT_SIZE[d.alert] ?? 12) * sizeScale,
|
||||||
getFillColor: (d) => RISK_RGBA[d.dto.algorithms.riskScore.level] ?? [100, 100, 100, 40],
|
getFillColor: (d) => ALERT_RGBA[d.alert] ?? [100, 100, 100, 40],
|
||||||
getLineColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [100, 100, 100, 200],
|
getLineColor: (d) => ALERT_RGBA_BORDER[d.alert] ?? [100, 100, 100, 200],
|
||||||
stroked: true,
|
stroked: true,
|
||||||
filled: true,
|
filled: true,
|
||||||
radiusUnits: 'pixels',
|
radiusUnits: 'pixels',
|
||||||
@ -123,13 +124,13 @@ export function useAnalysisDeckLayers(
|
|||||||
data: riskData,
|
data: riskData,
|
||||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||||
getText: (d) => {
|
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;
|
const name = d.ship.name || d.ship.mmsi;
|
||||||
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
|
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
|
||||||
},
|
},
|
||||||
getSize: 10 * sizeScale * afs,
|
getSize: 10 * sizeScale * afs,
|
||||||
updateTriggers: { getSize: [sizeScale] },
|
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',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
getPixelOffset: [0, 16],
|
getPixelOffset: [0, 16],
|
||||||
|
|||||||
@ -7,6 +7,46 @@ from algorithms.dark_vessel import detect_ais_gaps
|
|||||||
from algorithms.spoofing import detect_teleportation
|
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(
|
def compute_vessel_risk_score(
|
||||||
mmsi: str,
|
mmsi: str,
|
||||||
df_vessel: pd.DataFrame,
|
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
|
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
|
# Properties
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@ -177,6 +177,68 @@ def run_analysis_cycle():
|
|||||||
features=c.get('features', {}),
|
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 모듈 레벨로 사이클 간 유지)
|
# 6. 환적 의심 탐지 (pair_history 모듈 레벨로 사이클 간 유지)
|
||||||
from algorithms.transshipment import detect_transshipment
|
from algorithms.transshipment import detect_transshipment
|
||||||
|
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user