fix: hotfix 동기화 — history/detail candidate_count 안전 처리 #225

병합
htlee hotfix/sync-candidate-count 에서 develop 로 69 commits 를 머지했습니다 2026-04-04 11:05:43 +09:00
10개의 변경된 파일368개의 추가작업 그리고 129개의 파일을 삭제
Showing only changes of commit f0094c21d3 - Show all commits

파일 보기

@ -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,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<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>

파일 보기

@ -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,

파일 보기

@ -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