release: 2026-04-14 (5건 커밋) #38

병합
htlee develop 에서 main 로 5 commits 를 머지했습니다 2026-04-14 08:21:25 +09:00
10개의 변경된 파일743개의 추가작업 그리고 60개의 파일을 삭제
Showing only changes of commit 3d7896b4f2 - Show all commits

1
.gitignore vendored
파일 보기

@ -54,6 +54,7 @@ frontend/.vite/
# === 대용량/참고 문서 ===
*.hwpx
*.docx
# === Claude Code ===
!.claude/

파일 보기

@ -4,6 +4,17 @@
## [Unreleased]
### 추가
- **DarkVesselDetection 판정 상세 패널** — 테이블 행 클릭 시 점수 산출 내역(P1~P11), GAP 상세, 7일 이력 차트 사이드 패널 표시
- **ScoreBreakdown 공통 컴포넌트** — 가점/감점 분리 점수 내역 시각화
- **darkVesselPatterns 카탈로그 확장** — prediction 실제 판정 패턴 18종 한국어 라벨+점수+설명 + buildScoreBreakdown() 유틸
- **TransferDetection 환적 운영 화면** — 5단계 파이프라인 기반 재구성 (KPI, 쌍 목록, 쌍 상세, 감시영역 지도, 탐지 조건 시각화)
- **GearDetection 모선 추론 연동** — 모선 상태(DIRECT_MATCH/AUTO_PROMOTED/REVIEW_REQUIRED), 추정 모선 MMSI, 후보 수 컬럼
### 변경
- **DarkVesselDetection 위치 표시 수정** — lat/lon null 시 features.gap_start_lat/lon fallback, 클릭 시 지도 하이라이트
- **EnforcementPlan 탐지 기반 단속 대상** — CRITICAL 이벤트를 카테고리별(다크베셀/환적/EEZ침범/고위험) 아이콘+라벨로 통합 표시
## [2026-04-13.2]
### 변경

파일 보기

@ -13,6 +13,7 @@ import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getRiskIntent } from '@shared/constants/statusIntent';
import { useSettingsStore } from '@stores/settingsStore';
import { DarkDetailPanel } from './components/DarkDetailPanel';
/* SFR-09: Dark Vessel 탐지 — prediction 직접 API 기반 */
@ -51,6 +52,10 @@ function mapToSuspect(v: VesselAnalysis, idx: number): Suspect {
const darkScore = (feat.dark_suspicion_score as number) ?? 0;
const patterns = (feat.dark_patterns as string[]) ?? [];
// 위치: lat/lon이 없으면 features.gap_start_lat/lon 사용
const lat = (v.lat && v.lat !== 0) ? v.lat : (feat.gap_start_lat as number) ?? 0;
const lon = (v.lon && v.lon !== 0) ? v.lon : (feat.gap_start_lon as number) ?? 0;
return {
id: `DV-${String(idx + 1).padStart(3, '0')}`,
mmsi: v.mmsi,
@ -62,8 +67,8 @@ function mapToSuspect(v: VesselAnalysis, idx: number): Suspect {
risk: v.riskScore ?? 0,
gap: v.gapDurationMin ?? 0,
lastAIS: formatDateTime(v.analyzedAt),
lat: v.lat ?? 0,
lng: v.lon ?? 0,
lat,
lng: lon,
};
}
@ -74,6 +79,7 @@ export function DarkVesselDetection() {
const navigate = useNavigate();
const [tierFilter, setTierFilter] = useState<string>('');
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const cols: DataColumn<Suspect>[] = useMemo(() => [
{ key: 'id', label: 'ID', width: '70px',
@ -121,6 +127,12 @@ export function DarkVesselDetection() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 선택된 선박의 원본 VesselAnalysis 조회
const selectedVessel = useMemo(
() => selectedMmsi ? rawData.find(v => v.mmsi === selectedMmsi) ?? null : null,
[rawData, selectedMmsi],
);
const loadData = useCallback(async () => {
setLoading(true);
setError('');
@ -169,30 +181,56 @@ export function DarkVesselDetection() {
const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [
...createStaticLayers(),
createRadiusLayer(
'dv-radius',
DATA.filter((d) => d.darkScore >= 70).map((d) => ({
lat: d.lat, lng: d.lng, radius: 10000,
color: TIER_HEX[d.darkTier] || '#ef4444',
})),
0.08,
),
createMarkerLayer(
'dv-markers',
DATA.filter((d) => d.lat !== 0).map((d) => ({
lat: d.lat, lng: d.lng,
color: TIER_HEX[d.darkTier] || '#6b7280',
radius: d.darkScore >= 70 ? 1200 : 800,
label: `${d.id} ${d.name}`,
} as MarkerData)),
),
], [DATA]);
const buildLayers = useCallback(() => {
const validData = DATA.filter((d) => d.lat !== 0 && d.lng !== 0);
const layers = [
...createStaticLayers(),
// 전체 선박 마커 (tier별 색상)
createMarkerLayer(
'dv-markers',
validData.map((d) => ({
lat: d.lat, lng: d.lng,
color: TIER_HEX[d.darkTier] || '#6b7280',
radius: d.darkScore >= 70 ? 1000 : 600,
label: `${d.id}`,
} as MarkerData)),
),
// CRITICAL 위험 반경
createRadiusLayer(
'dv-radius',
validData.filter((d) => d.darkScore >= 70).map((d) => ({
lat: d.lat, lng: d.lng, radius: 10000,
color: TIER_HEX[d.darkTier] || '#ef4444',
})),
0.08,
),
];
useMapLayers(mapRef, buildLayers, [DATA]);
// 클릭 선택 선박 하이라이트 (흰색 원 + 큰 마커)
if (selectedMmsi) {
const target = validData.find(d => d.mmsi === selectedMmsi);
if (target) {
layers.push(
createRadiusLayer(
'dv-highlight',
[{ lat: target.lat, lng: target.lng, radius: 15000, color: '#ffffff' }],
0.15,
),
createMarkerLayer(
'dv-highlight-marker',
[{ lat: target.lat, lng: target.lng, color: '#ffffff', radius: 2000, label: `${target.mmsi}` } as MarkerData],
),
);
}
}
return layers;
}, [DATA, selectedMmsi]);
useMapLayers(mapRef, buildLayers, [DATA, selectedMmsi]);
return (
<>
<PageContainer>
<PageHeader
icon={EyeOff}
@ -244,7 +282,8 @@ export function DarkVesselDetection() {
<DataTable data={DATA} columns={cols} pageSize={10}
searchPlaceholder="선박유형, MMSI, 패턴 검색..."
searchKeys={['name', 'mmsi', 'darkPatterns', 'flag', 'darkTier']}
exportFilename="Dark_Vessel_탐지" />
exportFilename="Dark_Vessel_탐지"
onRowClick={(row) => setSelectedMmsi(row.mmsi)} />
{/* 탐지 위치 지도 */}
<Card>
@ -270,5 +309,11 @@ export function DarkVesselDetection() {
</CardContent>
</Card>
</PageContainer>
{/* 판정 상세 사이드 패널 */}
{selectedVessel && (
<DarkDetailPanel vessel={selectedVessel} onClose={() => setSelectedMmsi(null)} />
)}
</>
);
}

파일 보기

@ -15,7 +15,7 @@ import { useSettingsStore } from '@stores/settingsStore';
/* SFR-10: 불법 어망·어구 탐지 및 관리 */
type Gear = { id: string; type: string; owner: string; zone: string; status: string; permit: string; installed: string; lastSignal: string; risk: string; lat: number; lng: number; [key: string]: unknown; };
type Gear = { id: string; type: string; owner: string; zone: string; status: string; permit: string; installed: string; lastSignal: string; risk: string; lat: number; lng: number; parentStatus: string; parentMmsi: string; confidence: string; [key: string]: unknown; };
// 한글 위험도 → AlertLevel hex 매핑
const RISK_HEX: Record<string, string> = {
@ -52,6 +52,9 @@ function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
risk,
lat: g.centerLat,
lng: g.centerLon,
parentStatus: g.resolution?.status ?? '-',
parentMmsi: g.resolution?.selectedParentMmsi ?? '-',
confidence: g.candidateCount != null ? `${g.candidateCount}` : '-',
};
}
@ -71,6 +74,17 @@ export function GearDetection() {
render: v => <Badge intent={getGearJudgmentIntent(v as string)} size="sm">{v as string}</Badge> },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } },
{ key: 'parentStatus', label: '모선 상태', width: '100px', sortable: true,
render: v => {
const s = v as string;
const intent = s === 'DIRECT_PARENT_MATCH' ? 'success' : s === 'AUTO_PROMOTED' ? 'info' : s === 'REVIEW_REQUIRED' ? 'warning' : s === 'UNRESOLVED' ? 'muted' : 'muted';
const label = s === 'DIRECT_PARENT_MATCH' ? '직접매칭' : s === 'AUTO_PROMOTED' ? '자동승격' : s === 'REVIEW_REQUIRED' ? '심사필요' : s === 'UNRESOLVED' ? '미결정' : s;
return <Badge intent={intent} size="sm">{label}</Badge>;
} },
{ key: 'parentMmsi', label: '추정 모선', width: '100px',
render: v => { const m = v as string; return m !== '-' ? <span className="text-cyan-400 font-mono text-[10px]">{m}</span> : <span className="text-hint">-</span>; } },
{ key: 'confidence', label: '후보', width: '50px', align: 'center',
render: v => <span className="font-mono text-[10px] text-label">{v as string}</span> },
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
], [tc, lang]);

파일 보기

@ -0,0 +1,191 @@
/**
* DarkDetailPanel Dark Vessel
*
* .
* , , GAP , .
*/
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { ScoreBreakdown } from '@shared/components/common/ScoreBreakdown';
import { buildScoreBreakdown } from '@shared/constants/darkVesselPatterns';
import { getRiskIntent } from '@shared/constants/statusIntent';
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi';
import { X, Ship, MapPin, Clock, AlertTriangle, TrendingUp, ExternalLink, ShieldAlert } from 'lucide-react';
import { BarChart as EcBarChart } from '@lib/charts';
interface DarkDetailPanelProps {
vessel: VesselAnalysis | null;
onClose: () => void;
}
export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
const navigate = useNavigate();
const [history, setHistory] = useState<VesselAnalysis[]>([]);
const features = vessel?.features ?? {};
const darkTier = (features.dark_tier as string) ?? 'NONE';
const darkScore = (features.dark_suspicion_score as number) ?? 0;
const darkPatterns = (features.dark_patterns as string[]) ?? [];
const darkHistory7d = (features.dark_history_7d as number) ?? 0;
const darkHistory24h = (features.dark_history_24h as number) ?? 0;
const gapStartLat = features.gap_start_lat as number | undefined;
const gapStartLon = features.gap_start_lon as number | undefined;
const gapStartSog = features.gap_start_sog as number | undefined;
const gapStartState = features.gap_start_state as string | undefined;
// 점수 산출 내역
const breakdown = useMemo(() => buildScoreBreakdown(darkPatterns), [darkPatterns]);
// 7일 이력 조회
const loadHistory = useCallback(async () => {
if (!vessel?.mmsi) return;
try {
const res = await getAnalysisHistory(vessel.mmsi, 168); // 7일
setHistory(res);
} catch { setHistory([]); }
}, [vessel?.mmsi]);
useEffect(() => { loadHistory(); }, [loadHistory]);
// 일별 dark 건수 집계 (차트용)
const dailyDarkData = useMemo(() => {
const dayMap: Record<string, number> = {};
for (const h of history) {
if (!h.isDark) continue;
const day = (h.analyzedAt ?? '').slice(0, 10);
if (day) dayMap[day] = (dayMap[day] || 0) + 1;
}
return Object.entries(dayMap)
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, count]) => ({ name: day.slice(5), value: count }));
}, [history]);
if (!vessel) return null;
return (
<div className="fixed inset-y-0 right-0 w-[420px] bg-background border-l border-border z-50 overflow-y-auto shadow-2xl">
{/* 헤더 */}
<div className="sticky top-0 bg-background/95 backdrop-blur-sm border-b border-border px-4 py-3 flex items-center justify-between z-10">
<div className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-red-400" />
<span className="font-bold text-heading text-sm"> </span>
<Badge intent={getAlertLevelIntent(darkTier)} size="sm">{darkTier}</Badge>
<span className="text-xs font-mono font-bold text-heading">{darkScore}</span>
</div>
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label="닫기">
<X className="w-4 h-4 text-hint" />
</button>
</div>
<div className="p-4 space-y-4">
{/* 선박 기본 정보 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Ship className="w-3.5 h-3.5 text-cyan-400" />
<span className="text-label font-medium"> </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint">MMSI</span>
<button type="button" className="text-cyan-400 hover:underline text-right font-mono"
onClick={() => navigate(`/vessel/${vessel.mmsi}`)}>
{vessel.mmsi} <ExternalLink className="w-2.5 h-2.5 inline" />
</button>
<span className="text-hint"></span>
<span className="text-label text-right">{vessel.vesselType || 'UNKNOWN'}</span>
<span className="text-hint"></span>
<span className="text-label text-right">{vessel.mmsi?.startsWith('412') ? 'CN (중국)' : vessel.mmsi?.slice(0, 3)}</span>
<span className="text-hint"></span>
<span className="text-label text-right">{vessel.zoneCode || '-'}</span>
<span className="text-hint"></span>
<span className="text-label text-right">{vessel.activityState || '-'}</span>
<span className="text-hint"></span>
<span className="text-right">
<Badge intent={getRiskIntent(vessel.riskScore ?? 0)} size="sm">
{vessel.riskLevel} ({vessel.riskScore})
</Badge>
</span>
</div>
</div>
{/* 점수 산출 내역 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<AlertTriangle className="w-3.5 h-3.5 text-orange-400" />
<span className="text-label font-medium"> </span>
<span className="text-hint text-[10px]">({breakdown.items.length} )</span>
</div>
<ScoreBreakdown items={breakdown.items} totalScore={darkScore} />
</div>
{/* GAP 상세 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<MapPin className="w-3.5 h-3.5 text-yellow-400" />
<span className="text-label font-medium">GAP </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint">GAP </span>
<span className="text-label text-right font-mono">
{vessel.gapDurationMin ? `${vessel.gapDurationMin}분 (${(vessel.gapDurationMin / 60).toFixed(1)}h)` : '-'}
</span>
<span className="text-hint"> </span>
<span className="text-label text-right font-mono text-[10px]">
{gapStartLat != null ? `${gapStartLat.toFixed(4)}°N ${gapStartLon?.toFixed(4)}°E` : '-'}
</span>
<span className="text-hint"> SOG</span>
<span className="text-label text-right font-mono">
{gapStartSog != null ? `${gapStartSog.toFixed(1)}kn` : '-'}
</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{gapStartState || '-'}</span>
<span className="text-hint"></span>
<span className="text-label text-right text-[10px]">{formatDateTime(vessel.analyzedAt)}</span>
</div>
</div>
{/* 과거 이력 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<TrendingUp className="w-3.5 h-3.5 text-purple-400" />
<span className="text-label font-medium"> (7)</span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint">7 dark </span>
<span className="text-label text-right font-bold">{darkHistory7d}</span>
<span className="text-hint">24 dark</span>
<span className="text-label text-right">{darkHistory24h}</span>
</div>
{dailyDarkData.length > 0 && (
<div className="h-24 mt-2">
<EcBarChart
data={dailyDarkData}
xKey="name"
series={[{ key: 'value', name: 'Dark 건수', color: '#ef4444' }]}
height={96}
/>
</div>
)}
{dailyDarkData.length === 0 && (
<div className="text-hint text-[10px] text-center py-2"> </div>
)}
</div>
{/* 액션 버튼 */}
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1"
onClick={() => navigate(`/vessel/${vessel.mmsi}`)}>
<Ship className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="primary" size="sm" className="flex-1"
onClick={() => { /* TODO: 단속 대상 등록 API 연동 */ }}>
<Clock className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
</div>
);
}

파일 보기

@ -7,7 +7,7 @@ import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { getRiskIntent, getStatusIntent } from '@shared/constants/statusIntent';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users, Loader2 } from 'lucide-react';
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users, Loader2, EyeOff, RefreshCw } from 'lucide-react';
import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement';
@ -165,26 +165,41 @@ export function EnforcementPlan() {
</div>
))}
</div>
{/* 미배정 CRITICAL 이벤트 */}
{/* 탐지 기반 단속 대상 (CRITICAL 이벤트 통합) */}
{criticalEvents.length > 0 && (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="w-4 h-4 text-red-400" />
<span className="text-[12px] font-bold text-heading"> CRITICAL </span>
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="critical" size="xs">{criticalEvents.length}</Badge>
<span className="text-[9px] text-hint ml-auto"> · · EEZ · </span>
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{criticalEvents.map((evt) => (
<div key={evt.id} className="flex items-center gap-2 px-3 py-2 bg-red-500/5 border border-red-500/20 rounded-lg">
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
{getAlertLevelLabel(evt.level, tc, lang)}
</Badge>
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
<span className="text-[9px] text-hint shrink-0">{formatDateTime(evt.occurredAt)}</span>
<span className="text-[9px] text-cyan-400 font-mono shrink-0">{evt.vesselMmsi ?? '-'}</span>
</div>
))}
<div className="space-y-1.5 max-h-64 overflow-y-auto">
{criticalEvents.map((evt) => {
const cat = evt.category ?? '';
const catIcon = cat === 'DARK_VESSEL' ? EyeOff
: cat === 'ILLEGAL_TRANSSHIP' ? RefreshCw
: cat === 'EEZ_INTRUSION' ? Shield
: AlertTriangle;
const catLabel = cat === 'DARK_VESSEL' ? '다크베셀'
: cat === 'ILLEGAL_TRANSSHIP' ? '환적 의심'
: cat === 'EEZ_INTRUSION' ? 'EEZ 침범'
: cat === 'HIGH_RISK_VESSEL' ? '고위험'
: cat || '기타';
const CatIcon = catIcon;
return (
<div key={evt.id} className="flex items-center gap-2 px-3 py-2 bg-red-500/5 border border-red-500/20 rounded-lg">
<CatIcon className="w-3.5 h-3.5 text-red-400 shrink-0" />
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
{catLabel}
</Badge>
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
<span className="text-[9px] text-hint shrink-0">{formatDateTime(evt.occurredAt)}</span>
<span className="text-[9px] text-cyan-400 font-mono shrink-0">{evt.vesselMmsi ?? '-'}</span>
</div>
);
})}
</div>
</CardContent>
</Card>

파일 보기

@ -1,41 +1,296 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent } from '@shared/components/ui/card';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { RefreshCw } from 'lucide-react';
import { RealTransshipSuspects } from '@features/detection/RealVesselAnalysis';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { RefreshCw, AlertTriangle, Ship, Anchor, Loader2, MapPin, Clock, ArrowRight } from 'lucide-react';
import { BaseMap, createStaticLayers, createMarkerLayer, useMapLayers, type MapHandle } from '@lib/map';
import { getTransshipSuspects, type VesselAnalysis } from '@/services/analysisApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getRiskIntent } from '@shared/constants/statusIntent';
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
import { useSettingsStore } from '@stores/settingsStore';
/* 환적 의심 운영 화면 — prediction 5단계 필터 파이프라인 결과 표시 */
interface TransshipPair {
id: string;
mmsiA: string;
mmsiB: string;
duration: number;
score: number;
tier: string;
zone: string;
lat: number;
lng: number;
analyzedAt: string;
vesselTypeA: string;
vesselTypeB: string;
[key: string]: unknown;
}
function mapToPair(v: VesselAnalysis, idx: number): TransshipPair | null {
if (!v.transshipSuspect || !v.transshipPairMmsi) return null;
const feat = v.features ?? {};
return {
id: `TS-${String(idx + 1).padStart(3, '0')}`,
mmsiA: v.mmsi,
mmsiB: v.transshipPairMmsi,
duration: v.transshipDurationMin ?? 0,
score: (feat.transship_score as number) ?? 0,
tier: (feat.transship_tier as string) ?? 'HIGH',
zone: v.zoneCode ?? '-',
lat: v.lat ?? 0,
lng: v.lon ?? 0,
analyzedAt: formatDateTime(v.analyzedAt),
vesselTypeA: v.vesselType ?? 'UNKNOWN',
vesselTypeB: '-',
};
}
export function TransferDetection() {
const navigate = useNavigate();
const lang = useSettingsStore((s) => s.language);
const [rawData, setRawData] = useState<VesselAnalysis[]>([]);
const [loading, setLoading] = useState(false);
const [selectedPair, setSelectedPair] = useState<TransshipPair | null>(null);
const loadData = useCallback(async () => {
setLoading(true);
try {
const res = await getTransshipSuspects({ hours: 24, size: 200 });
setRawData(res.content);
} catch { /* silent */ }
finally { setLoading(false); }
}, []);
useEffect(() => { loadData(); }, [loadData]);
// 30초 자동 갱신
useEffect(() => {
const timer = setInterval(async () => {
try {
const res = await getTransshipSuspects({ hours: 24, size: 200 });
setRawData(res.content);
} catch { /* silent */ }
}, 30_000);
return () => clearInterval(timer);
}, []);
// 쌍 단위 중복 제거 (A↔B 동일 쌍)
const DATA: TransshipPair[] = useMemo(() => {
const seen = new Set<string>();
const pairs: TransshipPair[] = [];
rawData.forEach((v, i) => {
const p = mapToPair(v, i);
if (!p) return;
const key = [p.mmsiA, p.mmsiB].sort().join('-');
if (seen.has(key)) return;
seen.add(key);
pairs.push(p);
});
return pairs.sort((a, b) => b.score - a.score);
}, [rawData]);
const kpi = useMemo(() => ({
total: DATA.length,
critical: DATA.filter(d => d.score >= 70).length,
high: DATA.filter(d => d.score >= 50 && d.score < 70).length,
}), [DATA]);
const cols: DataColumn<TransshipPair>[] = useMemo(() => [
{ key: 'id', label: 'ID', width: '70px',
render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'tier', label: '등급', width: '80px', sortable: true,
render: (v) => {
const tier = v as string;
return <Badge intent={getAlertLevelIntent(tier)} size="sm">{tier}</Badge>;
} },
{ key: 'score', label: '점수', width: '60px', align: 'center', sortable: true,
render: (v) => {
const n = v as number;
return <span className={`font-bold font-mono ${n >= 70 ? 'text-red-400' : 'text-orange-400'}`}>{n}</span>;
} },
{ key: 'mmsiA', label: '어선', width: '100px',
render: (v) => (
<button type="button" className="text-cyan-400 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${v as string}`); }}>
{v as string}
</button>
) },
{ key: 'mmsiB', label: '상대선박', width: '100px',
render: (v) => (
<button type="button" className="text-orange-400 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${v as string}`); }}>
{v as string}
</button>
) },
{ key: 'duration', label: '지속시간', width: '80px', align: 'right', sortable: true,
render: (v) => {
const min = v as number;
return <span className="font-mono text-[10px]">{min > 60 ? `${(min/60).toFixed(1)}h` : `${min}`}</span>;
} },
{ key: 'zone', label: '해역', width: '100px' },
{ key: 'analyzedAt', label: '탐지시각', width: '100px',
render: (v) => <span className="text-hint text-[10px]">{v as string}</span> },
], [navigate]);
// 지도
const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [
...createStaticLayers(),
// 어선 마커 (파랑)
createMarkerLayer('ts-fishing',
DATA.map(d => ({ lat: d.lat, lng: d.lng, color: '#3b82f6', radius: 1000, label: d.mmsiA })),
),
// 상대선 마커 (빨강) — 같은 위치에 오프셋
createMarkerLayer('ts-carrier',
DATA.map(d => ({ lat: d.lat + 0.003, lng: d.lng + 0.003, color: '#ef4444', radius: 1000, label: d.mmsiB })),
),
], [DATA]);
useMapLayers(mapRef, buildLayers, [DATA]);
return (
<PageContainer>
<PageHeader
icon={RefreshCw}
iconColor="text-cyan-400"
title="환적·접촉 탐지"
description="선박 간 근접 접촉 및 환적 의심 행위 분석"
title="환적 의심 탐지"
description="어선↔운반선 근접 체류 기반 환적 의심 행위 분석 (5단계 필터 파이프라인)"
/>
{/* prediction 분석 결과 기반 실시간 환적 의심 선박 */}
<RealTransshipSuspects />
{loading && (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-hint" />
</div>
)}
{/* 탐지 조건 */}
<Card className="bg-surface-overlay border-slate-700/40">
<CardContent className="p-5">
<div className="text-xs text-muted-foreground mb-3"> </div>
<div className="grid grid-cols-3 gap-4">
<div className="bg-surface-raised rounded-lg p-4">
<div className="text-[11px] text-hint mb-1"></div>
<div className="text-xl font-bold text-heading"> 100m</div>
{/* KPI */}
<div className="flex gap-2">
{[
{ label: '전체 의심', value: kpi.total, icon: AlertTriangle, color: 'text-red-400' },
{ label: 'CRITICAL', value: kpi.critical, icon: Ship, color: 'text-red-400' },
{ label: 'HIGH', value: kpi.high, icon: Anchor, color: 'text-orange-400' },
].map(k => (
<div key={k.label} className="flex-1 bg-card border border-border rounded-xl px-3 py-2 flex items-center gap-2">
<k.icon className={`w-4 h-4 ${k.color}`} />
<span className={`text-base font-bold ${k.color}`}>{k.value}</span>
<span className="text-[9px] text-hint">{k.label}</span>
</div>
))}
</div>
{/* 의심 목록 */}
<DataTable data={DATA} columns={cols} pageSize={10}
searchPlaceholder="MMSI 검색..."
searchKeys={['mmsiA', 'mmsiB', 'zone', 'tier']}
exportFilename="환적_의심_탐지"
onRowClick={(row) => setSelectedPair(row)}
/>
{/* 선택 쌍 상세 */}
{selectedPair && (
<Section title={`의심 쌍 상세 — ${selectedPair.id}`}>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs">
<Ship className="w-3.5 h-3.5 text-blue-400" />
<span className="text-label font-medium"></span>
</div>
<div className="bg-surface-raised rounded-lg p-3 text-xs space-y-1">
<div className="flex justify-between">
<span className="text-hint">MMSI</span>
<button type="button" className="text-cyan-400 hover:underline font-mono"
onClick={() => navigate(`/vessel/${selectedPair.mmsiA}`)}>
{selectedPair.mmsiA}
</button>
</div>
<div className="flex justify-between">
<span className="text-hint">()</span>
<span className="text-label">{selectedPair.vesselTypeA}</span>
</div>
</div>
</div>
<div className="bg-surface-raised rounded-lg p-4">
<div className="text-[11px] text-hint mb-1"></div>
<div className="text-xl font-bold text-heading"> 30</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs">
<Anchor className="w-3.5 h-3.5 text-orange-400" />
<span className="text-label font-medium"></span>
</div>
<div className="bg-surface-raised rounded-lg p-3 text-xs space-y-1">
<div className="flex justify-between">
<span className="text-hint">MMSI</span>
<button type="button" className="text-cyan-400 hover:underline font-mono"
onClick={() => navigate(`/vessel/${selectedPair.mmsiB}`)}>
{selectedPair.mmsiB}
</button>
</div>
</div>
</div>
<div className="bg-surface-raised rounded-lg p-4">
<div className="text-[11px] text-hint mb-1"></div>
<div className="text-xl font-bold text-heading"> 3kn</div>
</div>
<div className="bg-surface-raised rounded-lg p-3 mt-3 text-xs space-y-1">
<div className="flex justify-between">
<span className="text-hint"></span>
<span className="font-mono font-bold text-heading">
{selectedPair.duration} ({(selectedPair.duration / 60).toFixed(1)}h)
</span>
</div>
<div className="flex justify-between">
<span className="text-hint"></span>
<Badge intent={selectedPair.score >= 70 ? 'critical' : 'high'} size="sm">
{selectedPair.score} ({selectedPair.tier})
</Badge>
</div>
<div className="flex justify-between">
<span className="text-hint"></span>
<span className="font-mono text-label text-[10px]">
{selectedPair.lat.toFixed(4)}°N {selectedPair.lng.toFixed(4)}°E
</span>
</div>
<div className="flex justify-between">
<span className="text-hint"></span>
<span className="text-label">{selectedPair.zone}</span>
</div>
<div className="flex items-center justify-center gap-2 pt-2 text-hint text-[10px]">
<MapPin className="w-3 h-3" /> APPROACH <ArrowRight className="w-3 h-3" /> RENDEZVOUS ({selectedPair.duration}) <ArrowRight className="w-3 h-3" /> <Clock className="w-3 h-3" />
</div>
</div>
</Section>
)}
{/* 탐지 위치 지도 */}
<Card>
<CardContent className="p-0 relative">
<BaseMap ref={mapRef} center={[35.0, 126.0]} zoom={7} height={400} className="rounded-lg overflow-hidden" />
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-[9px] text-hint"></span>
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-[9px] text-hint"></span>
<span className="text-[10px] text-label font-bold ml-2">{DATA.length}</span>
</div>
</CardContent>
</Card>
{/* 탐지 조건 */}
<Section title="탐지 조건 (5단계 파이프라인)">
<div className="grid grid-cols-5 gap-2">
{[
{ label: 'Stage 1', desc: '이종 쌍 필수', detail: '어선↔화물/유조선' },
{ label: 'Stage 2', desc: '감시영역', detail: '서해EEZ·남해·동해' },
{ label: 'Stage 3', desc: '패턴 검증', detail: '접근→체류90분+→분리' },
{ label: 'Stage 4', desc: '점수 산출', detail: '50점 이상만 출력' },
{ label: 'Stage 5', desc: '밀집 방폭', detail: '1운반선:1어선' },
].map(s => (
<div key={s.label} className="bg-surface-raised rounded-lg p-3 text-center">
<div className="text-[10px] text-cyan-400 font-bold">{s.label}</div>
<div className="text-xs text-heading font-medium mt-1">{s.desc}</div>
<div className="text-[9px] text-hint mt-0.5">{s.detail}</div>
</div>
))}
</div>
</Section>
</PageContainer>
);
}

파일 보기

@ -0,0 +1,77 @@
/**
* ScoreBreakdown
*
* 용도: dark_patterns, transship /
* SSOT: shared/constants/darkVesselPatterns.ts (DARK_SCORING_PATTERNS)
*/
import { Badge } from '@shared/components/ui/badge';
import type { ScoringPatternMeta } from '@shared/constants/darkVesselPatterns';
interface ScoreBreakdownProps {
items: (ScoringPatternMeta & { code: string })[];
totalScore: number;
maxScore?: number;
className?: string;
}
export function ScoreBreakdown({
items,
totalScore,
maxScore = 100,
className = '',
}: ScoreBreakdownProps) {
const addItems = items.filter(i => i.score > 0);
const subItems = items.filter(i => i.score < 0);
return (
<div className={`space-y-2 ${className}`}>
{/* 가점 항목 */}
{addItems.map(item => (
<div key={item.code} className="flex items-center gap-2 text-xs">
<span className="w-14 text-right font-mono font-semibold text-emerald-400">
{item.scoreLabel}
</span>
<Badge intent={item.intent} size="sm">
{item.label}
</Badge>
<span className="text-hint truncate">{item.desc}</span>
</div>
))}
{/* 감점 항목 */}
{subItems.length > 0 && (
<>
<div className="border-t border-white/5 my-1" />
{subItems.map(item => (
<div key={item.code} className="flex items-center gap-2 text-xs">
<span className="w-14 text-right font-mono font-semibold text-blue-400">
{item.scoreLabel}
</span>
<Badge intent={item.intent} size="sm">
{item.label}
</Badge>
<span className="text-hint truncate">{item.desc}</span>
</div>
))}
</>
)}
{/* 합계 */}
<div className="border-t border-white/10 pt-2 flex items-center gap-2 text-xs">
<span className="w-14 text-right font-mono font-bold text-heading">
{totalScore}
</span>
<span className="text-label">/ {maxScore}</span>
<div className="flex-1 h-1.5 bg-surface-raised rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.min(100, Math.max(0, totalScore))}%`,
backgroundColor: totalScore >= 70 ? '#ef4444' : totalScore >= 50 ? '#f97316' : totalScore >= 30 ? '#eab308' : '#64748b',
}}
/>
</div>
</div>
</div>
);
}

파일 보기

@ -9,3 +9,4 @@ export { PrintButton } from './PrintButton';
export { SaveButton } from './SaveButton';
export { DataTable, type DataColumn } from './DataTable';
export { NotificationBanner, NotificationPopup, type SystemNotice, type NoticeType, type NoticeDisplay } from './NotificationBanner';
export { ScoreBreakdown } from './ScoreBreakdown';

파일 보기

@ -89,3 +89,76 @@ export function getDarkVesselPatternLabel(
if (!meta) return p;
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
}
// ─── prediction 실제 판정 패턴 (dark_vessel.py P1~P11) ──────────
// features.dark_patterns 배열에 저장되는 코드 → 한국어 라벨 + 점수 매핑
export interface ScoringPatternMeta {
label: string;
labelEn: string;
score: number; // 양수=가점, 음수=감점
scoreLabel: string; // "+25" / "-50"
desc: string;
descEn: string;
intent: BadgeIntent;
category: 'movement' | 'zone' | 'history' | 'identity' | 'signal' | 'coverage';
}
export const DARK_SCORING_PATTERNS: Record<string, ScoringPatternMeta> = {
// P1: 이동 상태
moving_at_off: { label: '이동중 OFF', labelEn: 'Moving at OFF', score: 25, scoreLabel: '+25', desc: 'SOG > 5kn에서 AIS 꺼짐', descEn: 'AIS off while SOG > 5kn', intent: 'critical', category: 'movement' },
slow_moving_at_off: { label: '저속 이동중 OFF', labelEn: 'Slow moving at OFF', score: 15, scoreLabel: '+15', desc: 'SOG 2~5kn에서 AIS 꺼짐', descEn: 'AIS off while SOG 2~5kn', intent: 'high', category: 'movement' },
// P2: 수역
sensitive_zone: { label: '민감 수역', labelEn: 'Sensitive zone', score: 25, scoreLabel: '+25', desc: '영해/접속수역에서 gap 시작', descEn: 'Gap started in territorial/contiguous zone', intent: 'critical', category: 'zone' },
special_zone: { label: '특정수역', labelEn: 'Special zone', score: 15, scoreLabel: '+15', desc: '특정어업수역에서 gap 시작', descEn: 'Gap started in special fishing zone', intent: 'high', category: 'zone' },
// P3: 반복 이력
repeat_high: { label: '반복 dark (고)', labelEn: 'Repeat dark (high)', score: 30, scoreLabel: '+30', desc: '7일내 3일+ dark 이력', descEn: '3+ dark days in 7 days', intent: 'critical', category: 'history' },
repeat_low: { label: '반복 dark (저)', labelEn: 'Repeat dark (low)', score: 15, scoreLabel: '+15', desc: '7일내 2일 dark 이력', descEn: '2 dark days in 7 days', intent: 'warning', category: 'history' },
recent_dark: { label: '최근 dark', labelEn: 'Recent dark', score: 10, scoreLabel: '+10', desc: '24시간내 dark 이력', descEn: 'Dark within 24h', intent: 'warning', category: 'history' },
// P4: 이동거리
distance_anomaly: { label: '이동거리 이상', labelEn: 'Distance anomaly', score: 20, scoreLabel: '+20', desc: 'gap 중 예상 대비 2배+ 이동', descEn: 'Moved 2x+ expected during gap', intent: 'high', category: 'movement' },
// P5: 조업 시간
daytime_fishing_off: { label: '주간 조업중 OFF', labelEn: 'Daytime fishing OFF', score: 15, scoreLabel: '+15', desc: '06~18시 조업 중 AIS 꺼짐', descEn: 'AIS off while fishing 06-18h', intent: 'high', category: 'movement' },
// P6: 이상 행동
teleport_before_gap: { label: 'gap 전 텔레포트', labelEn: 'Teleport before gap', score: 15, scoreLabel: '+15', desc: 'gap 직전 위치 점프', descEn: 'Position jump before gap', intent: 'high', category: 'signal' },
// P7: 무허가
unpermitted: { label: '무허가', labelEn: 'Unpermitted', score: 10, scoreLabel: '+10', desc: '허가 목록 미등록 선박', descEn: 'Not in permit registry', intent: 'warning', category: 'identity' },
// P8: gap 길이
very_long_gap: { label: '장기 gap (6h+)', labelEn: 'Very long gap (6h+)', score: 15, scoreLabel: '+15', desc: '360분 이상 gap', descEn: 'Gap >= 360min', intent: 'high', category: 'signal' },
long_gap: { label: 'gap (3h+)', labelEn: 'Long gap (3h+)', score: 10, scoreLabel: '+10', desc: '180분 이상 gap', descEn: 'Gap >= 180min', intent: 'warning', category: 'signal' },
// P9: 선종 (signal-batch 보강)
fishing_vessel_dark: { label: '어선 dark', labelEn: 'Fishing vessel dark', score: 10, scoreLabel: '+10', desc: '어선(000020)의 의도적 OFF 가능성', descEn: 'Fishing vessel intentional OFF', intent: 'warning', category: 'identity' },
cargo_natural_gap: { label: '화물선 자연 gap', labelEn: 'Cargo natural gap', score: -10, scoreLabel: '-10', desc: '화물선 원양 항해 자연 gap', descEn: 'Cargo vessel ocean gap (natural)', intent: 'info', category: 'identity' },
// P10: 항해 상태
underway_deliberate_off: { label: '항행중 의도적 OFF', labelEn: 'Underway deliberate OFF', score: 20, scoreLabel: '+20', desc: '항행 상태에서 갑자기 OFF', descEn: 'AIS off while under way', intent: 'critical', category: 'movement' },
anchored_natural_gap: { label: '정박중 자연 gap', labelEn: 'Anchored natural gap', score: -15, scoreLabel: '-15', desc: '정박/계류 중 gap은 자연스러움', descEn: 'Gap while anchored/moored (natural)', intent: 'info', category: 'movement' },
// P11: heading/COG
heading_cog_mismatch: { label: '방향 불일치', labelEn: 'Heading/COG mismatch', score: 15, scoreLabel: '+15', desc: '선수방향과 침로 60°+ 차이', descEn: 'Heading vs COG diff > 60°', intent: 'high', category: 'signal' },
// 감점
out_of_coverage: { label: '커버리지 밖', labelEn: 'Out of coverage', score: -50, scoreLabel: '-50', desc: 'AIS 수신범위 외 → 자연 gap', descEn: 'Outside AIS coverage → natural gap', intent: 'muted', category: 'coverage' },
};
/** prediction dark_patterns 코드로 scoring 메타 조회 */
export function getScoringPatternMeta(code: string): ScoringPatternMeta | undefined {
return DARK_SCORING_PATTERNS[code];
}
/** dark_patterns 배열 → 점수 내역 (가점/감점 분리) */
export function buildScoreBreakdown(patterns: string[]): {
items: (ScoringPatternMeta & { code: string })[];
totalAdd: number;
totalSub: number;
rawTotal: number;
} {
const items = patterns
.map(code => {
const meta = DARK_SCORING_PATTERNS[code];
return meta ? { ...meta, code } : null;
})
.filter((v): v is ScoringPatternMeta & { code: string } => v !== null)
.sort((a, b) => b.score - a.score);
const totalAdd = items.filter(i => i.score > 0).reduce((s, i) => s + i.score, 0);
const totalSub = items.filter(i => i.score < 0).reduce((s, i) => s + i.score, 0);
return { items, totalAdd, totalSub, rawTotal: totalAdd + totalSub };
}