feat(frontend): 워크플로우 연결 Step 3 — VesselDetail 강화 + DarkVessel prediction 전환

VesselDetail:
- iran proxy → prediction 직접 API 전환 (getAnalysisLatest/getAnalysisHistory)
- dark 패턴 시각화: dark_tier Badge, 의심점수 바, dark_patterns 태그, 7일 반복 횟수
- 환적 의심 분석 섹션 추가 (transship_tier, transship_score)
- 24h AIS 수신 이력 타임라인 그래프 (시간대별 수신/소실 막대)
- 단속 이력 탭 신설 (GET /api/enforcement/records?vesselMmsi)
- 지도 중심좌표를 분석 결과의 lat/lon으로 자동 설정
- 위험도 점수 표시 0~100 직접 사용 (iran proxy의 0~1 변환 제거)

DarkVesselDetection:
- iran proxy → getDarkVessels() 직접 API 전환
- derivePattern() 제거 → features.dark_tier/dark_suspicion_score/dark_patterns 직접 표시
- tier 기반 KPI 카드 (CRITICAL/HIGH/WATCH) + 클릭 필터
- 의심 점수 내림차순 정렬 (가장 의심스러운 순)
- tier별 필터 셀렉트 추가
- 지도 범례: tier 기반 색상

enforcement.ts: getEnforcementRecords에 vesselMmsi 파라미터 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-09 11:02:46 +09:00
부모 0679c04bfe
커밋 1940caf73b
3개의 변경된 파일369개의 추가작업 그리고 227개의 파일을 삭제

파일 보기

@ -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<string, string> = {
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,12 +73,25 @@ export function DarkVesselDetection() {
const lang = useSettingsStore((s) => s.language);
const navigate = useNavigate();
const [tierFilter, setTierFilter] = useState<string>('');
const cols: DataColumn<Suspect>[] = useMemo(() => [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true,
render: v => <Badge intent={getDarkVesselPatternIntent(v as string)} size="sm">{getDarkVesselPatternLabel(v as string, tc, lang)}</Badge> },
{ key: 'name', label: '선박 유형', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
{ key: 'mmsi', label: 'MMSI', width: '100px', render: (v) => {
{ key: 'id', label: 'ID', width: '70px',
render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'darkTier', label: '등급', width: '80px', sortable: true,
render: (v) => {
const tier = v as string;
return <Badge intent={getRiskIntent(tier === 'CRITICAL' ? 90 : tier === 'HIGH' ? 60 : tier === 'WATCH' ? 40 : 10)} size="sm">{tier}</Badge>;
} },
{ key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true,
render: (v) => {
const n = v as number;
return <span className={`font-bold font-mono ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}</span>;
} },
{ key: 'name', label: '선박 유형', sortable: true,
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span> },
{ key: 'mmsi', label: 'MMSI', width: '100px',
render: (v) => {
const mmsi = v as string;
return (
<button type="button" className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
@ -88,17 +101,23 @@ export function DarkVesselDetection() {
);
} },
{ key: 'flag', label: '국적', width: '50px' },
{ key: 'gap', label: 'AIS 공백', width: '80px', align: 'right', sortable: true,
render: (v) => {
const min = v as number;
return <span className="text-label font-mono text-[10px]">{min > 0 ? `${min}` : '-'}</span>;
} },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: v => { const n = v as number; return <span className={`font-bold ${n > 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; } },
{ key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: v => <Badge intent={getVesselSurveillanceIntent(v as string)} size="sm">{getVesselSurveillanceLabel(v as string, tc, lang)}</Badge> },
{ key: 'label', label: '라벨', width: '60px', align: 'center',
render: v => { const l = v as string; return l === '-' ? <button type="button" className="text-[9px] text-hint hover:text-blue-400"><Tag className="w-3 h-3 inline" /> </button> : <Badge intent={l === '불법' ? 'critical' : 'success'} size="xs">{l}</Badge>; } },
], [tc, lang]);
render: (v) => {
const n = v as number;
return <span className={`font-bold ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>;
} },
{ key: 'darkPatterns', label: '의심 패턴', minWidth: '120px',
render: (v) => <span className="text-hint text-[9px]">{v as string}</span> },
{ key: 'lastAIS', label: '분석시각', width: '90px',
render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
], [tc, lang, navigate]);
const [darkItems, setDarkItems] = useState<VesselAnalysisItem[]>([]);
const [serviceAvailable, setServiceAvailable] = useState(true);
const [rawData, setRawData] = useState<VesselAnalysis[]>([]);
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<MapHandle>(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,14 +188,19 @@ export function DarkVesselDetection() {
iconColor="text-red-400"
title={t('darkVessel.title')}
description={t('darkVessel.desc')}
/>
{!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>iran - Dark Vessel </span>
actions={
<div className="flex items-center gap-1">
<Filter className="w-3.5 h-3.5 text-hint" />
<Select size="sm" value={tierFilter} onChange={(e) => setTierFilter(e.target.value)}
title="등급 필터" className="w-32">
<option value=""> </option>
<option value="CRITICAL">CRITICAL</option>
<option value="HIGH">HIGH</option>
<option value="WATCH">WATCH</option>
</Select>
</div>
)}
}
/>
{error && <div className="text-xs text-red-400">: {error}</div>}
@ -181,49 +210,51 @@ export function DarkVesselDetection() {
</div>
)}
{/* KPI — tier 기반 */}
<div className="flex gap-2">
{[
{ 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 => (
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
{ 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) => (
<div key={k.l}
onClick={() => 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'
}`}>
<AlertTriangle className={`w-4 h-4 ${k.c}`} />
<span className={`text-base font-bold ${k.c}`}>{k.v}</span>
<span className="text-[9px] text-hint">{k.l}</span>
</div>
))}
</div>
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="선박유형, MMSI, 패턴 검색..." searchKeys={['name', 'mmsi', 'pattern', 'flag']} exportFilename="Dark_Vessel_탐지" />
<DataTable data={DATA} columns={cols} pageSize={10}
searchPlaceholder="선박유형, MMSI, 패턴 검색..."
searchKeys={['name', 'mmsi', 'darkPatterns', 'flag', 'darkTier']}
exportFilename="Dark_Vessel_탐지" />
{/* 탐지 위치 지도 */}
<Card>
<CardContent className="p-0 relative">
<BaseMap ref={mapRef} center={[36.5, 127.5]} zoom={7} height={450} className="rounded-lg overflow-hidden" />
{/* 범례 */}
{/* 범례 — tier 기반 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1.5"> </div>
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">Dark Tier</div>
<div className="space-y-1">
{(['AIS_FULL_BLOCK', 'MMSI_SPOOFING', 'LONG_LOSS', 'INTERMITTENT'] as const).map((p) => {
const meta = getDarkVesselPatternMeta(p);
if (!meta) return null;
return (
<div key={p} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: meta.hex }} />
<span className="text-[8px] text-muted-foreground">{meta.fallback.ko}</span>
{(['CRITICAL', 'HIGH', 'WATCH', 'NONE'] as const).map((tier) => (
<div key={tier} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: TIER_HEX[tier] }} />
<span className="text-[8px] text-muted-foreground">{tier}</span>
</div>
);
})}
</div>
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div>
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div>
))}
</div>
</div>
<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-red-500 animate-pulse" />
<span className="text-[10px] text-red-400 font-bold">{DATA.filter(d => d.risk > 80).length}</span>
<span className="text-[9px] text-hint"> Dark Vessel </span>
<span className="text-[10px] text-red-400 font-bold">{tierCounts.CRITICAL}</span>
<span className="text-[9px] text-hint">CRITICAL Dark Vessel</span>
</div>
</CardContent>
</Card>

파일 보기

@ -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 (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-hint">24h AIS </span>
<span className="text-[10px] text-label font-mono">{received}/24h ({Math.round(received / 24 * 100)}%)</span>
</div>
<div className="flex gap-px h-3">
{slots.map((s, i) => (
<div
key={i}
className={`flex-1 rounded-sm ${s.hasData ? 'bg-green-500' : 'bg-red-500/40'}`}
title={`${String(s.hour).padStart(2, '0')}시 — ${s.hasData ? '수신' : '소실'}`}
/>
))}
</div>
<div className="flex justify-between mt-0.5">
<span className="text-[7px] text-hint">-24h</span>
<span className="text-[7px] text-hint"></span>
</div>
</div>
);
}
// ─── 메인 컴포넌트 ────────────────────
export function VesselDetail() {
const { id: mmsiParam } = useParams<{ id: string }>();
// 데이터 상태
const [vessel, setVessel] = useState<VesselAnalysisItem | null>(null);
const [analysis, setAnalysis] = useState<VesselAnalysis | null>(null);
const [history, setHistory] = useState<VesselAnalysis[]>([]);
const [permit, setPermit] = useState<VesselPermitData | null>(null);
const [events, setEvents] = useState<PredictionEvent[]>([]);
const [serviceAvailable, setServiceAvailable] = useState(true);
const [enforcements, setEnforcements] = useState<EnforcementRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 검색 상태 (검색 패널용)
// 검색 상태
const [searchMmsi, setSearchMmsi] = useState(mmsiParam ?? '');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const mapRef = useRef<MapHandle>(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,8 +159,7 @@ export function VesselDetail() {
}, [mmsiParam]);
// 지도 레이어
const buildLayers = useCallback(() => {
const 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,
@ -138,13 +169,7 @@ export function VesselDetail() {
color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4],
})
),
];
// 선박 위치가 없으므로 분석 데이터의 zone 기반으로 대략적 위치 표시는 불가
// vessel-analysis에는 좌표가 없으므로 마커 생략
return layers;
}, []);
], []);
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 (
<PageContainer fullBleed className="flex h-[calc(100vh-7.5rem)] gap-0">
@ -201,16 +235,6 @@ export function VesselDetail() {
</div>
)}
{!serviceAvailable && !loading && !error && (
<div className="p-3 mx-3 mt-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-center gap-2">
<WifiOff className="w-4 h-4 text-yellow-400" />
<span className="text-[11px] text-yellow-400 font-medium"> </span>
</div>
<p className="text-[10px] text-hint mt-1">iran .</p>
</div>
)}
{/* 선박 정보 */}
{!loading && !error && (
<div className="flex-1 overflow-y-auto">
@ -223,17 +247,17 @@ export function VesselDetail() {
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
{[
['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) => (
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
@ -268,8 +292,8 @@ export function VesselDetail() {
</div>
)}
{/* AI 분석 결과 */}
{vessel && (
{/* AI 분석 결과 — prediction 직접 데이터 */}
{analysis && (
<div className="p-3 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<Brain className="w-4 h-4 text-purple-400" />
@ -286,14 +310,14 @@ export function VesselDetail() {
</div>
<div className="flex items-baseline gap-1 mb-1">
<span className={`text-xl font-bold ${riskMeta.classes.text}`}>
{Math.round(riskScore * 100)}
{riskScore}
</span>
<span className="text-[10px] text-hint">/100</span>
</div>
<div className="h-1.5 bg-switch-background rounded-full overflow-hidden">
<div
className="h-1.5 bg-gradient-to-r from-red-600 to-red-400 rounded-full transition-all"
style={{ width: `${riskScore * 100}%` }}
style={{ width: `${Math.min(riskScore, 100)}%` }}
/>
</div>
</div>
@ -301,21 +325,17 @@ export function VesselDetail() {
{/* 알고리즘 상세 */}
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
{[
['활동 상태', 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) => (
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
@ -329,8 +349,68 @@ export function VesselDetail() {
</div>
)}
{/* Dark 패턴 시각화 — features 기반 */}
{analysis?.isDark && darkTier && (
<div className="p-3 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<EyeOff className="w-4 h-4 text-red-400" />
<span className="text-[11px] font-bold text-heading">Dark Vessel </span>
</div>
<div className="space-y-2">
{/* Dark tier + score */}
<div className="flex items-center gap-2">
<Badge intent={getRiskIntent(darkScore ?? 0)} size="sm">{darkTier}</Badge>
<span className="text-[10px] text-label font-mono">{darkScore ?? 0}</span>
{darkHistory7d != null && darkHistory7d > 0 && (
<span className="text-[9px] text-red-400">7 {darkHistory7d} </span>
)}
</div>
{/* 의심 점수 바 */}
<div className="h-1.5 bg-switch-background rounded-full overflow-hidden">
<div
className="h-1.5 rounded-full transition-all"
style={{
width: `${Math.min(darkScore ?? 0, 100)}%`,
backgroundColor: (darkScore ?? 0) >= 70 ? '#ef4444' : (darkScore ?? 0) >= 50 ? '#f97316' : '#eab308',
}}
/>
</div>
{/* Dark 패턴 태그 */}
{darkPatterns && darkPatterns.length > 0 && (
<div className="flex flex-wrap gap-1">
{darkPatterns.map((p) => (
<Badge key={p} intent="muted" size="xs">{p}</Badge>
))}
</div>
)}
</div>
</div>
)}
{/* 환적 분석 — features 기반 */}
{analysis?.transshipSuspect && transshipTier && (
<div className="p-3 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<Shield className="w-4 h-4 text-orange-400" />
<span className="text-[11px] font-bold text-heading"> </span>
</div>
<div className="flex items-center gap-2">
<Badge intent={getRiskIntent(transshipScore ?? 0)} size="sm">{transshipTier}</Badge>
<span className="text-[10px] text-label font-mono">{transshipScore ?? 0}</span>
<span className="text-[9px] text-hint">: {analysis.transshipPairMmsi ?? '-'}</span>
</div>
</div>
)}
{/* 24h AIS 수신 이력 */}
{history.length > 0 && (
<div className="p-3 border-b border-border">
<AisTimeline history={history} />
</div>
)}
{/* 관련 이벤트 이력 */}
<div className="p-3">
<div className="p-3 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-red-400" />
<span className="text-[11px] font-bold text-heading"> </span>
@ -340,8 +420,7 @@ export function VesselDetail() {
<div className="text-[10px] text-hint text-center py-4"> .</div>
) : (
<div className="space-y-1.5">
{events.map((evt) => {
return (
{events.map((evt) => (
<div key={evt.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
<div className="flex items-center gap-2 mb-0.5">
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
@ -353,14 +432,41 @@ export function VesselDetail() {
</Badge>
</div>
<div className="text-[9px] text-hint">
{evt.occurredAt} {evt.areaName ? `| ${evt.areaName}` : ''}
{formatDateTime(evt.occurredAt)} {evt.areaName ? `| ${evt.areaName}` : ''}
</div>
</div>
))}
</div>
{evt.detail && (
<div className="text-[9px] text-muted-foreground mt-0.5 truncate">{evt.detail}</div>
)}
</div>
);
})}
{/* 단속 이력 */}
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<FileText className="w-4 h-4 text-green-400" />
<span className="text-[11px] font-bold text-heading"> </span>
<span className="text-[9px] text-hint ml-auto">{enforcements.length}</span>
</div>
{enforcements.length === 0 ? (
<div className="text-[10px] text-hint text-center py-4"> .</div>
) : (
<div className="space-y-1.5">
{enforcements.map((enf) => (
<div key={enf.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
<div className="flex items-center gap-2 mb-0.5">
<Badge intent="info" size="xs">{enf.enfUid}</Badge>
<span className="text-[10px] text-heading font-medium flex-1 truncate">
{enf.violationType ?? '단속'}
</span>
<Badge intent={enf.result === 'PUNISHED' ? 'critical' : 'muted'} size="xs">
{enf.result ?? '-'}
</Badge>
</div>
<div className="text-[9px] text-hint">
{formatDateTime(enf.enforcedAt)} {enf.areaName ? `| ${enf.areaName}` : ''}
</div>
</div>
))}
</div>
)}
</div>
@ -376,7 +482,7 @@ export function VesselDetail() {
<div className="flex items-center gap-2">
<Ship className="w-4 h-4 text-blue-400" />
<span className="text-[11px] font-bold text-heading">MMSI: {mmsiParam}</span>
{vessel && (
{analysis && (
<Badge intent={riskMeta.intent} size="sm">
: {getAlertLevelLabel(riskLevel, tc, lang)}
</Badge>
@ -387,8 +493,11 @@ export function VesselDetail() {
<BaseMap
ref={mapRef}
center={[34.5, 126.5]}
zoom={7}
center={[
analysis?.lat ?? 34.5,
analysis?.lon ?? 126.5,
]}
zoom={analysis?.lat ? 9 : 7}
height="100%"
/>
@ -397,15 +506,15 @@ export function VesselDetail() {
<span className="flex items-center gap-1 text-[8px]">
<MapPin className="w-2.5 h-2.5 text-green-400" />
<span className="text-hint"></span>
<span className="text-green-400 font-mono font-bold">34.5000</span>
<span className="text-green-400 font-mono font-bold">{analysis?.lat?.toFixed(4) ?? '-'}</span>
</span>
<span className="flex items-center gap-1 text-[8px]">
<MapPin className="w-2.5 h-2.5 text-green-400" />
<span className="text-hint"></span>
<span className="text-green-400 font-mono font-bold">126.5000</span>
<span className="text-green-400 font-mono font-bold">{analysis?.lon?.toFixed(4) ?? '-'}</span>
</span>
<span className="text-[8px]">
<span className="text-blue-400 font-bold">UTC</span>
<span className="text-blue-400 font-bold">KST</span>
<span className="text-label font-mono ml-1">{formatDateTime(new Date())}</span>
</span>
</div>

파일 보기

@ -81,11 +81,13 @@ export interface EnforcementPlan {
export async function getEnforcementRecords(params?: {
violationType?: string;
vesselMmsi?: string;
page?: number;
size?: number;
}): Promise<PageResponse<EnforcementRecord>> {
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}`, {