Merge pull request 'release: 2026-04-14 (5건 커밋)' (#38) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
This commit is contained in:
커밋
0bc8883bb8
1
.gitignore
vendored
1
.gitignore
vendored
@ -54,6 +54,7 @@ frontend/.vite/
|
||||
|
||||
# === 대용량/참고 문서 ===
|
||||
*.hwpx
|
||||
*.docx
|
||||
|
||||
# === Claude Code ===
|
||||
!.claude/
|
||||
|
||||
@ -4,15 +4,19 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-04-13.2]
|
||||
## [2026-04-14]
|
||||
|
||||
### 추가
|
||||
- **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침범/고위험) 아이콘+라벨로 통합 표시
|
||||
- **LGCNS 3개 페이지 디자인 시스템 전환** — LGCNSMLOps/AISecurityPage/AIAgentSecurityPage 공통 구조 적용
|
||||
- 커스텀 탭 → TabBar/TabButton 공통 컴포넌트 교체
|
||||
- hex 색상 맵 → Tailwind 토큰, `style={{ }}` 인라인 제거
|
||||
- 인라인 Badge intent 삼항 → 카탈로그 함수 교체 (getAgentPermTypeIntent 등)
|
||||
- 신규 카탈로그 4종: MLOps Job 상태, AI 위협 수준, Agent 권한 유형, Agent 실행 결과
|
||||
- catalogRegistry 등록 → design-system.html 쇼케이스 자동 노출
|
||||
|
||||
## [2026-04-13]
|
||||
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
191
frontend/src/features/detection/components/DarkDetailPanel.tsx
Normal file
191
frontend/src/features/detection/components/DarkDetailPanel.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
77
frontend/src/shared/components/common/ScoreBreakdown.tsx
Normal file
77
frontend/src/shared/components/common/ScoreBreakdown.tsx
Normal file
@ -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 };
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user