diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 6cdc555..6ed0ba3 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,18 @@ ## [Unreleased] +### 추가 +- 현장분석 항적 미니맵: 선박 클릭 시 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] ### 변경 diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx index e8f0368..53d2d7a 100644 --- a/frontend/src/components/korea/AnalysisStatsPanel.tsx +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -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 = { - CRITICAL: '#ef4444', - HIGH: '#f97316', - MEDIUM: '#eab308', - LOW: '#22c55e', -}; - -const RISK_EMOJI: Record = { - 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(null); + const [selectedLevel, setSelectedLevel] = useState(null); const [selectedMmsi, setSelectedMmsi] = useState(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, {/* 위험도 카운트 행 — 클릭 가능 */}
- {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 ( ); })} @@ -306,12 +299,12 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, <>
- {RISK_EMOJI[selectedLevel]} {selectedLevel} — {vesselList.length}척 + {ALERT_EMOJI[selectedLevel]} {selectedLevel} — {vesselList.length}척
{vesselList.map(item => { const isExpanded = selectedMmsi === item.mmsi; - const color = RISK_COLOR[selectedLevel]; + const color = ALERT_COLOR[selectedLevel]; const { dto } = item; return (
diff --git a/frontend/src/components/korea/FieldAnalysisModal.tsx b/frontend/src/components/korea/FieldAnalysisModal.tsx index fdf0abd..3f899bf 100644 --- a/frontend/src/components/korea/FieldAnalysisModal.tsx +++ b/frontend/src/components/korea/FieldAnalysisModal.tsx @@ -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(); @@ -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 = { @@ -142,35 +139,20 @@ 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; - const cluster = clusterId >= 0 ? `C-${String(clusterId).padStart(2, '0')}` : '—'; - - return { ship, zone, state, alert, vtype, cluster }; - }); + 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(null); @@ -506,6 +496,35 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor {val}
))} + + {/* 위험도 점수 기준 */} +
+ 위험도 점수 기준 +
+
+ {[ + { 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 }) => ( +
+
{title}
+ {items.map(item =>
{item}
)} +
+ ))} +
+ CRITICAL ≥70 + WATCH ≥50 + MONITOR ≥30 + NORMAL {'<'}30 +
+
+ UCAF: 어구별 조업속도 매칭 비율
+ UCFT: 조업-항행 구분 신뢰도
+ 스푸핑: 순간이동+SOG급변+BD09 종합 +
+
{/* ── 중앙 패널: 선박 테이블 */} @@ -840,6 +859,38 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
)}
+ + {/* ── 항적 미니맵 */} +
+
항적 미니맵
+
+ + {trackCoords.length > 1 && ( + + + + )} + +
+ + +
+
+ 최근 72시간 항적 · {trackCoords.length}포인트 +
+
) : (
diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx index ded3191..ee37b93 100644 --- a/frontend/src/components/korea/KoreaDashboard.tsx +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -334,7 +334,7 @@ export const KoreaDashboard = ({ /> )} {showReport && ( - setShowReport(false)} largestGearGroup={largestGearGroup} /> + setShowReport(false)} largestGearGroup={largestGearGroup} analysisMap={vesselAnalysis.analysisMap} /> )} {showOpsGuide && ( void; largestGearGroup?: { name: string; count: number }; + analysisMap?: Map; } 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(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 = { 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 = {}; @@ -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) { - {/* 5. 위험 분석 */} -

5. 위험 평가

+ {/* 5. 위험 평가 — Python AI 분석 결과 기반 */} +

5. 위험 평가 (AI 분석)

- + - - - + + + + + + + +
위험 유형현재 상태등급항목척수등급
다크베셀 의심{stats.darkSuspect.length}척 10 ? '#dc2626' : '#f59e0b' }}>{stats.darkSuspect.length > 10 ? 'HIGH' : 'MEDIUM'}
수역 외 어선{stats.zoneStats.OUTSIDE}척 0 ? '#dc2626' : '#22c55e' }}>{stats.zoneStats.OUTSIDE > 0 ? 'CRITICAL' : 'NORMAL'}
조업 중 어선{stats.cnOperating.length}척MONITOR
총 분석 대상{stats.analysisTotal}척
CRITICAL (긴급){stats.riskCounts.critical}척CRITICAL
WATCH (경고){stats.riskCounts.watch}척WATCH
MONITOR (주의){stats.riskCounts.monitor}척MONITOR
NORMAL (정상){stats.riskCounts.normal}척NORMAL
다크베셀 의심{stats.darkSuspect.length}척 0 ? ALERT_COLOR.WATCH : ALERT_COLOR.NORMAL }}>{stats.darkSuspect.length > 0 ? 'WATCH' : 'NORMAL'}
GPS 스푸핑 의심{stats.spoofingCount}척 0 ? ALERT_COLOR.WATCH : ALERT_COLOR.NORMAL }}>{stats.spoofingCount > 0 ? 'WATCH' : 'NORMAL'}
diff --git a/frontend/src/constants/riskMapping.ts b/frontend/src/constants/riskMapping.ts new file mode 100644 index 0000000..d00ef01 --- /dev/null +++ b/frontend/src/constants/riskMapping.ts @@ -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 = { + CRITICAL: 'CRITICAL', + HIGH: 'WATCH', + MEDIUM: 'MONITOR', + LOW: 'NORMAL', +}; + +export const ALERT_COLOR: Record = { + CRITICAL: '#FF5252', + WATCH: '#FFD740', + MONITOR: '#18FFFF', + NORMAL: '#00E676', +}; + +export const ALERT_EMOJI: Record = { + 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> = { + CRITICAL: 'critical', + WATCH: 'high', + MONITOR: 'medium', + NORMAL: 'low', +}; diff --git a/frontend/src/hooks/useAnalysisDeckLayers.ts b/frontend/src/hooks/useAnalysisDeckLayers.ts index 7726fe8..5cf065a 100644 --- a/frontend/src/hooks/useAnalysisDeckLayers.ts +++ b/frontend/src/hooks/useAnalysisDeckLayers.ts @@ -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 = { - CRITICAL: [239, 68, 68, 60], - HIGH: [249, 115, 22, 50], - MEDIUM: [234, 179, 8, 40], +// AlertLevel 기반 충전색 (현장분석 팔레트 통일) +const ALERT_RGBA: Record = { + 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 = { - CRITICAL: [239, 68, 68, 230], - HIGH: [249, 115, 22, 210], - MEDIUM: [234, 179, 8, 190], +const ALERT_RGBA_BORDER: Record = { + CRITICAL: [255, 82, 82, 230], + WATCH: [255, 215, 64, 210], + MONITOR: [24, 255, 255, 190], + NORMAL: [0, 230, 118, 160], }; -// 픽셀 반경 -const RISK_SIZE: Record = { +const ALERT_SIZE: Record = { CRITICAL: 18, - HIGH: 14, - MEDIUM: 12, + WATCH: 14, + MONITOR: 12, + NORMAL: 10, }; -const RISK_LABEL: Record = { +const ALERT_LABEL: Record = { CRITICAL: '긴급', - HIGH: '경고', - MEDIUM: '주의', + WATCH: '경고', + MONITOR: '주의', + NORMAL: '정상', }; -const RISK_PRIORITY: Record = { +const ALERT_PRIORITY: Record = { 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], diff --git a/prediction/algorithms/risk.py b/prediction/algorithms/risk.py index a5938f5..b4d3505 100644 --- a/prediction/algorithms/risk.py +++ b/prediction/algorithms/risk.py @@ -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, diff --git a/prediction/cache/vessel_store.py b/prediction/cache/vessel_store.py index 5e207d6..8f67bee 100644 --- a/prediction/cache/vessel_store.py +++ b/prediction/cache/vessel_store.py @@ -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 # ------------------------------------------------------------------ diff --git a/prediction/scheduler.py b/prediction/scheduler.py index 5360a3b..313ffdf 100644 --- a/prediction/scheduler.py +++ b/prediction/scheduler.py @@ -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