diff --git a/frontend/src/features/detection/DarkVesselDetection.tsx b/frontend/src/features/detection/DarkVesselDetection.tsx index 508dae9..815779f 100644 --- a/frontend/src/features/detection/DarkVesselDetection.tsx +++ b/frontend/src/features/detection/DarkVesselDetection.tsx @@ -3,44 +3,33 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Select } from '@shared/components/ui/select'; import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; -import { EyeOff, AlertTriangle, Radio, Tag, Loader2 } from 'lucide-react'; +import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { MarkerData } from '@lib/map'; -import { - fetchVesselAnalysis, - filterDarkVessels, - type VesselAnalysisItem, -} from '@/services/vesselAnalysisApi'; +import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi'; import { formatDateTime } from '@shared/utils/dateFormat'; -import { getDarkVesselPatternIntent, getDarkVesselPatternLabel, getDarkVesselPatternMeta } from '@shared/constants/darkVesselPatterns'; -import { getVesselSurveillanceIntent, getVesselSurveillanceLabel } from '@shared/constants/vesselAnalysisStatuses'; +import { getRiskIntent } from '@shared/constants/statusIntent'; import { useSettingsStore } from '@stores/settingsStore'; -/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */ +/* SFR-09: Dark Vessel 탐지 — prediction 직접 API 기반 */ -interface Suspect { id: string; mmsi: string; name: string; flag: string; pattern: string; risk: number; lastAIS: string; status: string; label: string; lat: number; lng: number; [key: string]: unknown; } - -const GAP_FULL_BLOCK_MIN = 1440; -const GAP_LONG_LOSS_MIN = 60; -const SPOOFING_THRESHOLD = 0.7; - -function derivePattern(item: VesselAnalysisItem): string { - const { gapDurationMin } = item.algorithms.darkVessel; - const { spoofingScore } = item.algorithms.gpsSpoofing; - if (gapDurationMin > GAP_FULL_BLOCK_MIN) return 'AIS 완전차단'; - if (spoofingScore > SPOOFING_THRESHOLD) return 'MMSI 변조 의심'; - if (gapDurationMin > GAP_LONG_LOSS_MIN) return '장기소실'; - return '신호 간헐송출'; -} - -function deriveStatus(item: VesselAnalysisItem): string { - const { score } = item.algorithms.riskScore; - if (score >= 80) return '추적중'; - if (score >= 50) return '감시중'; - if (score >= 30) return '확인중'; - return '정상'; +interface Suspect { + id: string; + mmsi: string; + name: string; + flag: string; + darkTier: string; + darkScore: number; + darkPatterns: string; + risk: number; + gap: number; + lastAIS: string; + lat: number; + lng: number; + [key: string]: unknown; } function deriveFlag(mmsi: string): string { @@ -49,21 +38,32 @@ function deriveFlag(mmsi: string): string { return '미상'; } -function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect { - const risk = item.algorithms.riskScore.score; - const status = deriveStatus(item); +const TIER_HEX: Record = { + CRITICAL: '#ef4444', + HIGH: '#f97316', + WATCH: '#eab308', + NONE: '#6b7280', +}; + +function mapToSuspect(v: VesselAnalysis, idx: number): Suspect { + const feat = v.features ?? {}; + const darkTier = (feat.dark_tier as string) ?? 'NONE'; + const darkScore = (feat.dark_suspicion_score as number) ?? 0; + const patterns = (feat.dark_patterns as string[]) ?? []; + return { id: `DV-${String(idx + 1).padStart(3, '0')}`, - mmsi: item.mmsi, - name: item.classification.vesselType || item.mmsi, - flag: deriveFlag(item.mmsi), - pattern: derivePattern(item), - risk, - lastAIS: formatDateTime(item.timestamp), - status, - label: risk >= 90 ? (status === '추적중' ? '불법' : '-') : status === '정상' ? '정상' : '-', - lat: 0, - lng: 0, + mmsi: v.mmsi, + name: v.vesselType || v.mmsi, + flag: deriveFlag(v.mmsi), + darkTier, + darkScore, + darkPatterns: patterns.join(', ') || '-', + risk: v.riskScore ?? 0, + gap: v.gapDurationMin ?? 0, + lastAIS: formatDateTime(v.analyzedAt), + lat: v.lat ?? 0, + lng: v.lon ?? 0, }; } @@ -73,32 +73,51 @@ export function DarkVesselDetection() { const lang = useSettingsStore((s) => s.language); const navigate = useNavigate(); - const cols: DataColumn[] = useMemo(() => [ - { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, - { key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, - render: v => {getDarkVesselPatternLabel(v as string, tc, lang)} }, - { key: 'name', label: '선박 유형', sortable: true, render: v => {v as string} }, - { key: 'mmsi', label: 'MMSI', width: '100px', render: (v) => { - const mmsi = v as string; - return ( - - ); - } }, - { key: 'flag', label: '국적', width: '50px' }, - { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, - render: v => { const n = v as number; return 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}; } }, - { key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => {v as string} }, - { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, - render: v => {getVesselSurveillanceLabel(v as string, tc, lang)} }, - { key: 'label', label: '라벨', width: '60px', align: 'center', - render: v => { const l = v as string; return l === '-' ? : {l}; } }, - ], [tc, lang]); + const [tierFilter, setTierFilter] = useState(''); - const [darkItems, setDarkItems] = useState([]); - const [serviceAvailable, setServiceAvailable] = useState(true); + const cols: DataColumn[] = useMemo(() => [ + { key: 'id', label: 'ID', width: '70px', + render: (v) => {v as string} }, + { key: 'darkTier', label: '등급', width: '80px', sortable: true, + render: (v) => { + const tier = v as string; + return {tier}; + } }, + { key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true, + render: (v) => { + const n = v as number; + return = 70 ? 'text-red-400' : n >= 50 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}; + } }, + { key: 'name', label: '선박 유형', sortable: true, + render: (v) => {v as string} }, + { key: 'mmsi', label: 'MMSI', width: '100px', + render: (v) => { + const mmsi = v as string; + return ( + + ); + } }, + { key: 'flag', label: '국적', width: '50px' }, + { key: 'gap', label: 'AIS 공백', width: '80px', align: 'right', sortable: true, + render: (v) => { + const min = v as number; + return {min > 0 ? `${min}분` : '-'}; + } }, + { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, + render: (v) => { + const n = v as number; + return = 70 ? 'text-red-400' : n >= 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}; + } }, + { key: 'darkPatterns', label: '의심 패턴', minWidth: '120px', + render: (v) => {v as string} }, + { key: 'lastAIS', label: '분석시각', width: '90px', + render: (v) => {v as string} }, + ], [tc, lang, navigate]); + + const [rawData, setRawData] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -106,12 +125,10 @@ export function DarkVesselDetection() { setLoading(true); setError(''); try { - const res = await fetchVesselAnalysis(); - setServiceAvailable(res.serviceAvailable); - setDarkItems(filterDarkVessels(res.items)); + const res = await getDarkVessels({ hours: 1, size: 500 }); + setRawData(res.content); } catch (e: unknown) { setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다'); - setServiceAvailable(false); } finally { setLoading(false); } @@ -119,15 +136,25 @@ export function DarkVesselDetection() { useEffect(() => { loadData(); }, [loadData]); - const DATA: Suspect[] = useMemo( - () => darkItems.map((item, i) => mapItemToSuspect(item, i)), - [darkItems], - ); + const DATA: Suspect[] = useMemo(() => { + let items = rawData.map((v, i) => mapToSuspect(v, i)); + if (tierFilter) { + items = items.filter((d) => d.darkTier === tierFilter); + } + // 의심 점수 내림차순 정렬 + return items.sort((a, b) => b.darkScore - a.darkScore); + }, [rawData, tierFilter]); - const avgRisk = useMemo( - () => DATA.length > 0 ? Math.round(DATA.reduce((s, d) => s + d.risk, 0) / DATA.length) : 0, - [DATA], - ); + // KPI 카운트 + const tierCounts = useMemo(() => { + const all = rawData.map((v) => ((v.features ?? {}).dark_tier as string) ?? 'NONE'); + return { + total: all.length, + CRITICAL: all.filter((t) => t === 'CRITICAL').length, + HIGH: all.filter((t) => t === 'HIGH').length, + WATCH: all.filter((t) => t === 'WATCH').length, + }; + }, [rawData]); const mapRef = useRef(null); @@ -135,21 +162,18 @@ export function DarkVesselDetection() { ...STATIC_LAYERS, createRadiusLayer( 'dv-radius', - DATA.filter(d => d.risk > 80).map(d => ({ - lat: d.lat, - lng: d.lng, - radius: 10000, - color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444', + 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.map(d => ({ - lat: d.lat, - lng: d.lng, - color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444', - radius: d.risk > 80 ? 1200 : 800, + 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)), ), @@ -164,15 +188,20 @@ export function DarkVesselDetection() { iconColor="text-red-400" title={t('darkVessel.title')} description={t('darkVessel.desc')} + actions={ +
+ + +
+ } /> - {!serviceAvailable && ( -
- - iran 분석 서비스 미연결 - 실시간 Dark Vessel 데이터를 불러올 수 없습니다 -
- )} - {error &&
에러: {error}
} {loading && ( @@ -181,49 +210,51 @@ export function DarkVesselDetection() { )} + {/* KPI — tier 기반 */}
{[ - { l: 'Dark Vessel', v: DATA.length, c: 'text-red-400', i: AlertTriangle }, - { l: 'AIS 완전차단', v: DATA.filter(d => d.pattern === 'AIS 완전차단').length, c: 'text-orange-400', i: EyeOff }, - { l: 'MMSI 변조', v: DATA.filter(d => d.pattern === 'MMSI 변조 의심').length, c: 'text-yellow-400', i: Radio }, - { l: `평균 위험도`, v: avgRisk, c: 'text-cyan-400', i: Tag }, - ].map(k => ( -
- {k.v}{k.l} + { l: '전체', v: tierCounts.total, c: 'text-red-400', filter: '' }, + { l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-400', filter: 'CRITICAL' }, + { l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-400', filter: 'HIGH' }, + { l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-400', filter: 'WATCH' }, + ].map((k) => ( +
setTierFilter(k.filter)} + className={`flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border cursor-pointer transition-colors ${ + tierFilter === k.filter ? 'bg-card border-blue-500/30' : 'bg-card border-border hover:border-border' + }`}> + + {k.v} + {k.l}
))}
- + {/* 탐지 위치 지도 */} - {/* 범례 */} + {/* 범례 — tier 기반 */}
-
탐지 패턴
+
Dark Tier
- {(['AIS_FULL_BLOCK', 'MMSI_SPOOFING', 'LONG_LOSS', 'INTERMITTENT'] as const).map((p) => { - const meta = getDarkVesselPatternMeta(p); - if (!meta) return null; - return ( -
-
- {meta.fallback.ko} -
- ); - })} -
-
-
EEZ
-
NLL
+ {(['CRITICAL', 'HIGH', 'WATCH', 'NONE'] as const).map((tier) => ( +
+
+ {tier} +
+ ))}
- {DATA.filter(d => d.risk > 80).length}척 - 고위험 Dark Vessel 탐지 + {tierCounts.CRITICAL}척 + CRITICAL Dark Vessel
diff --git a/frontend/src/features/vessel/VesselDetail.tsx b/frontend/src/features/vessel/VesselDetail.tsx index b97d198..cd76f31 100644 --- a/frontend/src/features/vessel/VesselDetail.tsx +++ b/frontend/src/features/vessel/VesselDetail.tsx @@ -6,16 +6,15 @@ import { Search, Ship, AlertTriangle, Radar, MapPin, Printer, Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain, - Loader2, WifiOff, ShieldAlert, + Loader2, ShieldAlert, Shield, EyeOff, FileText, } from 'lucide-react'; import { BaseMap, STATIC_LAYERS, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map'; -import { - fetchVesselAnalysis, - type VesselAnalysisItem, -} from '@/services/vesselAnalysisApi'; import { formatDateTime } from '@shared/utils/dateFormat'; import { getEvents, type PredictionEvent } from '@/services/event'; +import { getAnalysisLatest, getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi'; +import { getEnforcementRecords, type EnforcementRecord } from '@/services/enforcement'; import { ALERT_LEVELS, type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels'; +import { getRiskIntent } from '@shared/constants/statusIntent'; import { useSettingsStore } from '@stores/settingsStore'; import { useTranslation } from 'react-i18next'; @@ -57,27 +56,66 @@ const RIGHT_TOOLS = [ { icon: Printer, label: '인쇄' }, { icon: Camera, label: '스냅샷' }, ]; +// ─── 24h AIS 수신 막대 ─────────────── +function AisTimeline({ history }: { history: VesselAnalysis[] }) { + // 최근 24시간을 1시간 단위 슬롯으로 분할 + const now = Date.now(); + const slots = Array.from({ length: 24 }, (_, i) => { + const slotStart = now - (24 - i) * 3600_000; + const slotEnd = slotStart + 3600_000; + const hasData = history.some((h) => { + const t = new Date(h.analyzedAt).getTime(); + return t >= slotStart && t < slotEnd; + }); + return { hour: new Date(slotStart).getHours(), hasData }; + }); + const received = slots.filter((s) => s.hasData).length; + + return ( +
+
+ 24h AIS 수신 이력 + {received}/24h ({Math.round(received / 24 * 100)}%) +
+
+ {slots.map((s, i) => ( +
+ ))} +
+
+ -24h + 현재 +
+
+ ); +} + // ─── 메인 컴포넌트 ──────────────────── export function VesselDetail() { const { id: mmsiParam } = useParams<{ id: string }>(); // 데이터 상태 - const [vessel, setVessel] = useState(null); + const [analysis, setAnalysis] = useState(null); + const [history, setHistory] = useState([]); const [permit, setPermit] = useState(null); const [events, setEvents] = useState([]); - const [serviceAvailable, setServiceAvailable] = useState(true); + const [enforcements, setEnforcements] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // 검색 상태 (검색 패널용) + // 검색 상태 const [searchMmsi, setSearchMmsi] = useState(mmsiParam ?? ''); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const mapRef = useRef(null); - // 데이터 로드 + // 데이터 로드 — prediction 직접 API useEffect(() => { if (!mmsiParam) { setLoading(false); @@ -92,27 +130,21 @@ export function VesselDetail() { setError(null); try { - const [analysisRes, permitRes, eventsRes] = await Promise.all([ - fetchVesselAnalysis().catch(() => null), + const [analysisRes, historyRes, permitRes, eventsRes, enfRes] = await Promise.all([ + getAnalysisLatest(mmsiParam).catch(() => null), + getAnalysisHistory(mmsiParam, 24).catch(() => []), fetchVesselPermit(mmsiParam), getEvents({ vesselMmsi: mmsiParam, size: 10 }).catch(() => null), + getEnforcementRecords({ vesselMmsi: mmsiParam, size: 10 }).catch(() => null), ]); if (cancelled) return; - if (!analysisRes) { - setServiceAvailable(false); - setPermit(permitRes); - setEvents(eventsRes?.content ?? []); - setLoading(false); - return; - } - - setServiceAvailable(analysisRes.serviceAvailable); - const found = analysisRes.items.find((item) => item.mmsi === mmsiParam) ?? null; - setVessel(found); + setAnalysis(analysisRes); + setHistory(historyRes); setPermit(permitRes); setEvents(eventsRes?.content ?? []); + setEnforcements(enfRes?.content ?? []); } catch (err) { if (!cancelled) { setError(err instanceof Error ? err.message : '데이터 로드 실패'); @@ -127,24 +159,17 @@ export function VesselDetail() { }, [mmsiParam]); // 지도 레이어 - const buildLayers = useCallback(() => { - const layers = [ - ...STATIC_LAYERS, - createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({ - name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000, - })), 80000, 0.05), - ...DEPTH_CONTOURS.map((contour, i) => - createPolylineLayer(`depth-${i}`, contour.points as [number, number][], { - color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4], - }) - ), - ]; - - // 선박 위치가 없으므로 분석 데이터의 zone 기반으로 대략적 위치 표시는 불가 - // vessel-analysis에는 좌표가 없으므로 마커 생략 - - return layers; - }, []); + const buildLayers = useCallback(() => [ + ...STATIC_LAYERS, + createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({ + name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000, + })), 80000, 0.05), + ...DEPTH_CONTOURS.map((contour, i) => + createPolylineLayer(`depth-${i}`, contour.points as [number, number][], { + color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4], + }) + ), + ], []); useMapLayers(mapRef, buildLayers, []); @@ -152,11 +177,20 @@ export function VesselDetail() { const { t: tc } = useTranslation('common'); const lang = useSettingsStore((s) => s.language); - // 위험도 점수 바 - const riskScore = vessel?.algorithms.riskScore.score ?? 0; - const riskLevel = (vessel?.algorithms.riskScore.level ?? 'LOW') as AlertLevel; + // 위험도 + const riskScore = analysis?.riskScore ?? 0; + const riskLevel = (analysis?.riskLevel ?? 'LOW') as AlertLevel; const riskMeta = ALERT_LEVELS[riskLevel] ?? ALERT_LEVELS.LOW; + // features 추출 + const features = analysis?.features ?? {}; + const darkTier = features.dark_tier as string | undefined; + const darkScore = features.dark_suspicion_score as number | undefined; + const darkPatterns = features.dark_patterns as string[] | undefined; + const darkHistory7d = features.dark_history_7d as number | undefined; + const transshipTier = features.transship_tier as string | undefined; + const transshipScore = features.transship_score as number | undefined; + return ( @@ -201,16 +235,6 @@ export function VesselDetail() {
)} - {!serviceAvailable && !loading && !error && ( -
-
- - 분석 서비스 오프라인 -
-

iran 백엔드가 연결되지 않아 분석 데이터를 표시할 수 없습니다.

-
- )} - {/* 선박 정보 */} {!loading && !error && (
@@ -223,17 +247,17 @@ export function VesselDetail() {
{[ ['MMSI', mmsiParam ?? '-'], - ['선박 유형', vessel?.classification.vesselType ?? permit?.vesselType ?? '-'], + ['선박 유형', analysis?.vesselType ?? permit?.vesselType ?? '-'], ['국적', permit?.flagCountry ?? '-'], ['선명', permit?.vesselName ?? '-'], ['선명(중문)', permit?.vesselNameCn ?? '-'], ['톤수', permit?.tonnage != null ? `${permit.tonnage}톤` : '-'], ['길이', permit?.lengthM != null ? `${permit.lengthM}m` : '-'], ['건조년도', permit?.buildYear != null ? String(permit.buildYear) : '-'], - ['구역', vessel?.algorithms.location.zone ?? '-'], - ['기선거리', vessel?.algorithms.location.distToBaselineNm != null - ? `${vessel.algorithms.location.distToBaselineNm.toFixed(1)}nm` : '-'], - ['시즌', vessel?.classification.season ?? '-'], + ['구역', analysis?.zoneCode ?? '-'], + ['기선거리', analysis?.distToBaselineNm != null + ? `${Number(analysis.distToBaselineNm).toFixed(1)}nm` : '-'], + ['시즌', analysis?.season ?? '-'], ].map(([k, v], i) => (
{k} @@ -268,8 +292,8 @@ export function VesselDetail() {
)} - {/* AI 분석 결과 */} - {vessel && ( + {/* AI 분석 결과 — prediction 직접 데이터 */} + {analysis && (
@@ -286,14 +310,14 @@ export function VesselDetail() {
- {Math.round(riskScore * 100)} + {riskScore} /100
@@ -301,21 +325,17 @@ export function VesselDetail() { {/* 알고리즘 상세 */}
{[ - ['활동 상태', vessel.algorithms.activity.state], - ['UCAF 점수', vessel.algorithms.activity.ucafScore.toFixed(2)], - ['UCFT 점수', vessel.algorithms.activity.ucftScore.toFixed(2)], - ['다크베셀', vessel.algorithms.darkVessel.isDark ? '예 (의심)' : '아니오'], - ['AIS 공백', vessel.algorithms.darkVessel.gapDurationMin > 0 - ? `${vessel.algorithms.darkVessel.gapDurationMin}분` : '-'], - ['스푸핑 점수', vessel.algorithms.gpsSpoofing.spoofingScore.toFixed(2)], - ['BD09 오프셋', `${vessel.algorithms.gpsSpoofing.bd09OffsetM.toFixed(0)}m`], - ['속도 점프', `${vessel.algorithms.gpsSpoofing.speedJumpCount}회`], - ['클러스터', `#${vessel.algorithms.cluster.clusterId} (${vessel.algorithms.cluster.clusterSize}척)`], - ['선단 역할', vessel.algorithms.fleetRole.role], - ['환적 의심', vessel.algorithms.transship.isSuspect ? '예' : '아니오'], - ['환적 상대', vessel.algorithms.transship.pairMmsi || '-'], - ['환적 시간', vessel.algorithms.transship.durationMin > 0 - ? `${vessel.algorithms.transship.durationMin}분` : '-'], + ['활동 상태', analysis.activityState ?? '-'], + ['다크베셀', analysis.isDark ? '예 (의심)' : '아니오'], + ['AIS 공백', analysis.gapDurationMin != null && analysis.gapDurationMin > 0 + ? `${analysis.gapDurationMin}분` : '-'], + ['스푸핑 점수', analysis.spoofingScore != null ? Number(analysis.spoofingScore).toFixed(2) : '-'], + ['속도 점프', analysis.speedJumpCount != null ? `${analysis.speedJumpCount}회` : '-'], + ['선단 역할', analysis.fleetRole ?? '-'], + ['환적 의심', analysis.transshipSuspect ? '예' : '아니오'], + ['환적 상대', analysis.transshipPairMmsi || '-'], + ['환적 시간', analysis.transshipDurationMin != null && analysis.transshipDurationMin > 0 + ? `${analysis.transshipDurationMin}분` : '-'], ].map(([k, v], i) => (
{k} @@ -329,8 +349,68 @@ export function VesselDetail() {
)} + {/* Dark 패턴 시각화 — features 기반 */} + {analysis?.isDark && darkTier && ( +
+
+ + Dark Vessel 분석 +
+
+ {/* Dark tier + score */} +
+ {darkTier} + {darkScore ?? 0}점 + {darkHistory7d != null && darkHistory7d > 0 && ( + 7일간 {darkHistory7d}회 반복 + )} +
+ {/* 의심 점수 바 */} +
+
= 70 ? '#ef4444' : (darkScore ?? 0) >= 50 ? '#f97316' : '#eab308', + }} + /> +
+ {/* Dark 패턴 태그 */} + {darkPatterns && darkPatterns.length > 0 && ( +
+ {darkPatterns.map((p) => ( + {p} + ))} +
+ )} +
+
+ )} + + {/* 환적 분석 — features 기반 */} + {analysis?.transshipSuspect && transshipTier && ( +
+
+ + 환적 의심 분석 +
+
+ {transshipTier} + {transshipScore ?? 0}점 + 상대: {analysis.transshipPairMmsi ?? '-'} +
+
+ )} + + {/* 24h AIS 수신 이력 */} + {history.length > 0 && ( +
+ +
+ )} + {/* 관련 이벤트 이력 */} -
+
관련 이벤트 이력 @@ -340,27 +420,53 @@ export function VesselDetail() {
관련 이벤트가 없습니다.
) : (
- {events.map((evt) => { - return ( -
-
- - {getAlertLevelLabel(evt.level, tc, lang)} - - {evt.title} - - {evt.status} - -
-
- {evt.occurredAt} {evt.areaName ? `| ${evt.areaName}` : ''} -
- {evt.detail && ( -
{evt.detail}
- )} + {events.map((evt) => ( +
+
+ + {getAlertLevelLabel(evt.level, tc, lang)} + + {evt.title} + + {evt.status} +
- ); - })} +
+ {formatDateTime(evt.occurredAt)} {evt.areaName ? `| ${evt.areaName}` : ''} +
+
+ ))} +
+ )} +
+ + {/* 단속 이력 */} +
+
+ + 단속 이력 + {enforcements.length}건 +
+ {enforcements.length === 0 ? ( +
단속 이력이 없습니다.
+ ) : ( +
+ {enforcements.map((enf) => ( +
+
+ {enf.enfUid} + + {enf.violationType ?? '단속'} + + + {enf.result ?? '-'} + +
+
+ {formatDateTime(enf.enforcedAt)} {enf.areaName ? `| ${enf.areaName}` : ''} +
+
+ ))}
)}
@@ -376,7 +482,7 @@ export function VesselDetail() {
MMSI: {mmsiParam} - {vessel && ( + {analysis && ( 위험도: {getAlertLevelLabel(riskLevel, tc, lang)} @@ -387,8 +493,11 @@ export function VesselDetail() { @@ -397,15 +506,15 @@ export function VesselDetail() { 위도 - 34.5000 + {analysis?.lat?.toFixed(4) ?? '-'} 경도 - 126.5000 + {analysis?.lon?.toFixed(4) ?? '-'} - UTC + KST {formatDateTime(new Date())}
diff --git a/frontend/src/services/enforcement.ts b/frontend/src/services/enforcement.ts index 16475cd..a84b6ec 100644 --- a/frontend/src/services/enforcement.ts +++ b/frontend/src/services/enforcement.ts @@ -81,11 +81,13 @@ export interface EnforcementPlan { export async function getEnforcementRecords(params?: { violationType?: string; + vesselMmsi?: string; page?: number; size?: number; }): Promise> { const query = new URLSearchParams(); if (params?.violationType) query.set('violationType', params.violationType); + if (params?.vesselMmsi) query.set('vesselMmsi', params.vesselMmsi); query.set('page', String(params?.page ?? 0)); query.set('size', String(params?.size ?? 20)); const res = await fetch(`${API_BASE}/enforcement/records?${query}`, {