From d354c1ebc76048e1af9cf5a067eb8cee555d69b3 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 14 Apr 2026 07:56:52 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(frontend):=20=ED=83=90=EC=A7=80=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=9A=B4=EC=98=81=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20UI=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DarkVesselDetection: 판정 상세 사이드 패널(점수 산출 내역 P1~P11, GAP 상세, 7일 이력 차트), 선박 위치 gap_start_lat/lon fallback, 클릭 시 지도 하이라이트 - TransferDetection: 5단계 필터 기반 환적 운영 화면 재구성 (KPI, 쌍 목록, 쌍 상세, 감시영역 지도, 탐지 조건 시각화) - GearDetection: 모선 추론 상태(DIRECT_MATCH/AUTO_PROMOTED/REVIEW_REQUIRED), 추정 모선 MMSI, 후보 수 3개 컬럼 추가 - EnforcementPlan: CRITICAL 이벤트를 카테고리별(다크베셀/환적/EEZ침범/고위험) 아이콘+라벨로 "탐지 기반 단속 대상" 통합 표시 - darkVesselPatterns: prediction P1~P11 전 패턴 한국어 카탈로그 + buildScoreBreakdown() 점수 산출 유틸 - ScoreBreakdown: 가점/감점 분리 점수 내역 시각화 공통 컴포넌트 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + .../detection/DarkVesselDetection.tsx | 93 ++++-- .../src/features/detection/GearDetection.tsx | 16 +- .../detection/components/DarkDetailPanel.tsx | 191 +++++++++++ .../risk-assessment/EnforcementPlan.tsx | 43 ++- .../src/features/vessel/TransferDetection.tsx | 297 ++++++++++++++++-- .../components/common/ScoreBreakdown.tsx | 77 +++++ .../src/shared/components/common/index.ts | 1 + .../shared/constants/darkVesselPatterns.ts | 73 +++++ 9 files changed, 732 insertions(+), 60 deletions(-) create mode 100644 frontend/src/features/detection/components/DarkDetailPanel.tsx create mode 100644 frontend/src/shared/components/common/ScoreBreakdown.tsx diff --git a/.gitignore b/.gitignore index 06ed143..72ed76d 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ frontend/.vite/ # === 대용량/참고 문서 === *.hwpx +*.docx # === Claude Code === !.claude/ diff --git a/frontend/src/features/detection/DarkVesselDetection.tsx b/frontend/src/features/detection/DarkVesselDetection.tsx index 8dc12dd..83efa26 100644 --- a/frontend/src/features/detection/DarkVesselDetection.tsx +++ b/frontend/src/features/detection/DarkVesselDetection.tsx @@ -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(''); + const [selectedMmsi, setSelectedMmsi] = useState(null); const cols: DataColumn[] = 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(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 ( + <> + exportFilename="Dark_Vessel_탐지" + onRowClick={(row) => setSelectedMmsi(row.mmsi)} /> {/* 탐지 위치 지도 */} @@ -270,5 +309,11 @@ export function DarkVesselDetection() { + + {/* 판정 상세 사이드 패널 */} + {selectedVessel && ( + setSelectedMmsi(null)} /> + )} + ); } diff --git a/frontend/src/features/detection/GearDetection.tsx b/frontend/src/features/detection/GearDetection.tsx index 21b1669..6ae4430 100644 --- a/frontend/src/features/detection/GearDetection.tsx +++ b/frontend/src/features/detection/GearDetection.tsx @@ -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 = { @@ -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 => {v as string} }, { 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 {r}; } }, + { 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 {label}; + } }, + { key: 'parentMmsi', label: '추정 모선', width: '100px', + render: v => { const m = v as string; return m !== '-' ? {m} : -; } }, + { key: 'confidence', label: '후보', width: '50px', align: 'center', + render: v => {v as string} }, { key: 'lastSignal', label: '최종 신호', width: '80px', render: v => {v as string} }, ], [tc, lang]); diff --git a/frontend/src/features/detection/components/DarkDetailPanel.tsx b/frontend/src/features/detection/components/DarkDetailPanel.tsx new file mode 100644 index 0000000..63e0155 --- /dev/null +++ b/frontend/src/features/detection/components/DarkDetailPanel.tsx @@ -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([]); + + 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 = {}; + 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 ( +
+ {/* 헤더 */} +
+
+ + 판정 상세 + {darkTier} + {darkScore}점 +
+ +
+ +
+ {/* 선박 기본 정보 */} +
+
+ + 선박 정보 +
+
+ MMSI + + 선종 + {vessel.vesselType || 'UNKNOWN'} + 국적 + {vessel.mmsi?.startsWith('412') ? 'CN (중국)' : vessel.mmsi?.slice(0, 3)} + 해역 + {vessel.zoneCode || '-'} + 활동상태 + {vessel.activityState || '-'} + 위험도 + + + {vessel.riskLevel} ({vessel.riskScore}) + + +
+
+ + {/* 점수 산출 내역 */} +
+
+ + 점수 산출 내역 + ({breakdown.items.length}개 패턴 적용) +
+ +
+ + {/* GAP 상세 */} +
+
+ + GAP 상세 +
+
+ GAP 길이 + + {vessel.gapDurationMin ? `${vessel.gapDurationMin}분 (${(vessel.gapDurationMin / 60).toFixed(1)}h)` : '-'} + + 시작 위치 + + {gapStartLat != null ? `${gapStartLat.toFixed(4)}°N ${gapStartLon?.toFixed(4)}°E` : '-'} + + 시작 SOG + + {gapStartSog != null ? `${gapStartSog.toFixed(1)}kn` : '-'} + + 시작 상태 + {gapStartState || '-'} + 분석시각 + {formatDateTime(vessel.analyzedAt)} +
+
+ + {/* 과거 이력 */} +
+
+ + 과거 이력 (7일) +
+
+ 7일 dark 일수 + {darkHistory7d}일 + 24시간 dark + {darkHistory24h}일 +
+ {dailyDarkData.length > 0 && ( +
+ +
+ )} + {dailyDarkData.length === 0 && ( +
이력 없음
+ )} +
+ + {/* 액션 버튼 */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/risk-assessment/EnforcementPlan.tsx b/frontend/src/features/risk-assessment/EnforcementPlan.tsx index d391e4a..fb2063f 100644 --- a/frontend/src/features/risk-assessment/EnforcementPlan.tsx +++ b/frontend/src/features/risk-assessment/EnforcementPlan.tsx @@ -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() { ))} - {/* 미배정 CRITICAL 이벤트 */} + {/* 탐지 기반 단속 대상 (CRITICAL 이벤트 통합) */} {criticalEvents.length > 0 && (
- 미배정 CRITICAL 이벤트 + 탐지 기반 단속 대상 {criticalEvents.length}건 + 다크베셀 · 환적 · EEZ 침범 · 고위험 선박
-
- {criticalEvents.map((evt) => ( -
- - {getAlertLevelLabel(evt.level, tc, lang)} - - {evt.title} - {formatDateTime(evt.occurredAt)} - {evt.vesselMmsi ?? '-'} -
- ))} +
+ {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 ( +
+ + + {catLabel} + + {evt.title} + {formatDateTime(evt.occurredAt)} + {evt.vesselMmsi ?? '-'} +
+ ); + })}
diff --git a/frontend/src/features/vessel/TransferDetection.tsx b/frontend/src/features/vessel/TransferDetection.tsx index 0ca79c5..cebb4d0 100644 --- a/frontend/src/features/vessel/TransferDetection.tsx +++ b/frontend/src/features/vessel/TransferDetection.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [selectedPair, setSelectedPair] = useState(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(); + 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[] = useMemo(() => [ + { key: 'id', label: 'ID', width: '70px', + render: (v) => {v as string} }, + { key: 'tier', label: '등급', width: '80px', sortable: true, + render: (v) => { + const tier = v as string; + return {tier}; + } }, + { key: 'score', label: '점수', width: '60px', align: 'center', sortable: true, + render: (v) => { + const n = v as number; + return = 70 ? 'text-red-400' : 'text-orange-400'}`}>{n}; + } }, + { key: 'mmsiA', label: '어선', width: '100px', + render: (v) => ( + + ) }, + { key: 'mmsiB', label: '상대선박', width: '100px', + render: (v) => ( + + ) }, + { key: 'duration', label: '지속시간', width: '80px', align: 'right', sortable: true, + render: (v) => { + const min = v as number; + return {min > 60 ? `${(min/60).toFixed(1)}h` : `${min}분`}; + } }, + { key: 'zone', label: '해역', width: '100px' }, + { key: 'analyzedAt', label: '탐지시각', width: '100px', + render: (v) => {v as string} }, + ], [navigate]); + + // 지도 + const mapRef = useRef(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 ( - {/* prediction 분석 결과 기반 실시간 환적 의심 선박 */} - + {loading && ( +
+ +
+ )} - {/* 탐지 조건 */} - - -
탐지 조건
-
-
-
거리
-
≤ 100m
+ {/* KPI */} +
+ {[ + { 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 => ( +
+ + {k.value} + {k.label} +
+ ))} +
+ + {/* 의심 목록 */} + setSelectedPair(row)} + /> + + {/* 선택 쌍 상세 */} + {selectedPair && ( +
+
+
+
+ + 어선 +
+
+
+ MMSI + +
+
+ 선종(분류) + {selectedPair.vesselTypeA} +
+
-
-
시간
-
≥ 30분
+
+
+ + 상대선박 +
+
+
+ MMSI + +
+
-
-
속도
-
≤ 3kn
+
+
+
+ 지속시간 + + {selectedPair.duration}분 ({(selectedPair.duration / 60).toFixed(1)}h) +
+
+ 점수 + = 70 ? 'critical' : 'high'} size="sm"> + {selectedPair.score}점 ({selectedPair.tier}) + +
+
+ 위치 + + {selectedPair.lat.toFixed(4)}°N {selectedPair.lng.toFixed(4)}°E + +
+
+ 해역 + {selectedPair.zone} +
+
+ APPROACH RENDEZVOUS ({selectedPair.duration}분) +
+
+
+ )} + + {/* 탐지 위치 지도 */} + + + +
+
+ 어선 +
+ 운반선 + {DATA.length}쌍
+ + {/* 탐지 조건 */} +
+
+ {[ + { 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 => ( +
+
{s.label}
+
{s.desc}
+
{s.detail}
+
+ ))} +
+
); } diff --git a/frontend/src/shared/components/common/ScoreBreakdown.tsx b/frontend/src/shared/components/common/ScoreBreakdown.tsx new file mode 100644 index 0000000..a9c35f1 --- /dev/null +++ b/frontend/src/shared/components/common/ScoreBreakdown.tsx @@ -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 ( +
+ {/* 가점 항목 */} + {addItems.map(item => ( +
+ + {item.scoreLabel} + + + {item.label} + + {item.desc} +
+ ))} + + {/* 감점 항목 */} + {subItems.length > 0 && ( + <> +
+ {subItems.map(item => ( +
+ + {item.scoreLabel} + + + {item.label} + + {item.desc} +
+ ))} + + )} + + {/* 합계 */} +
+ + {totalScore} + + / {maxScore} +
+
= 70 ? '#ef4444' : totalScore >= 50 ? '#f97316' : totalScore >= 30 ? '#eab308' : '#64748b', + }} + /> +
+
+
+ ); +} diff --git a/frontend/src/shared/components/common/index.ts b/frontend/src/shared/components/common/index.ts index c718597..e58cf3b 100644 --- a/frontend/src/shared/components/common/index.ts +++ b/frontend/src/shared/components/common/index.ts @@ -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'; diff --git a/frontend/src/shared/constants/darkVesselPatterns.ts b/frontend/src/shared/constants/darkVesselPatterns.ts index 6a57b0e..2ed59db 100644 --- a/frontend/src/shared/constants/darkVesselPatterns.ts +++ b/frontend/src/shared/constants/darkVesselPatterns.ts @@ -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 = { + // 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 }; +} -- 2.45.2 From 56af7690fba591142c10f80333adf5393c884aa6 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 14 Apr 2026 08:09:34 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20[Unreleased]=20=ED=83=90=EC=A7=80=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20UI=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/RELEASE-NOTES.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 377ef8d..feca7d4 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -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] ### 변경 -- 2.45.2 From 907679769909e177de4eb14e1ef93fcc8c06d56c Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 14 Apr 2026 08:20:30 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-04-14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/RELEASE-NOTES.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index feca7d4..c4e08dc 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-04-14] + ### 추가 - **DarkVesselDetection 판정 상세 패널** — 테이블 행 클릭 시 점수 산출 내역(P1~P11), GAP 상세, 7일 이력 차트 사이드 패널 표시 - **ScoreBreakdown 공통 컴포넌트** — 가점/감점 분리 점수 내역 시각화 @@ -14,16 +16,7 @@ ### 변경 - **DarkVesselDetection 위치 표시 수정** — lat/lon null 시 features.gap_start_lat/lon fallback, 클릭 시 지도 하이라이트 - **EnforcementPlan 탐지 기반 단속 대상** — CRITICAL 이벤트를 카테고리별(다크베셀/환적/EEZ침범/고위험) 아이콘+라벨로 통합 표시 - -## [2026-04-13.2] - -### 변경 - **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] -- 2.45.2