Phase C-2 (인라인 <button>):
- TabBar/TabButton 공통 컴포넌트 신규 (underline/pill/segmented 3종)
- DataHub: 메인 탭 → TabBar + TabButton 전환, 필터 pill 전환,
CTA 버튼 (작업 등록/스토리지 관리/새로고침) → Button variant
- PermissionsPanel: 역할 생성/저장 → Button variant, icon 버튼 유지
- Python 일괄 치환: 51개 inline <button>에 type="button" 추가
- 남은 <button> type 누락 0건 (multi-line 포함)
Phase C-3 (하드코딩 색상):
- AdminPanel SERVER_STATUS 뱃지: getStatusIntent() 사용으로 통일
- bg-X-500/20 text-X-400 패턴 0건
Phase C-4 (인라인 style):
- LiveMapView BaseMap minHeight → className="min-h-[400px]"
- 나머지 89건 style={{}}은 모두 dynamic value
(progress width, toggle left, 데이터 기반 color 등)로 정당함
4개 catalog (eventStatuses/enforcementResults/enforcementActions/
patrolStatuses)에 intent 필드 추가, statusIntent.ts 공통 유틸 신규.
이제 모든 Badge가 쇼케이스 팔레트 자동 적용됨.
빌드 검증:
- tsc ✅, eslint ✅, vite build ✅
- 남은 위반 지표: Badge className 0, button-type-missing 0, 하드코딩 색상 0
223 lines
10 KiB
TypeScript
223 lines
10 KiB
TypeScript
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Card, CardContent } from '@shared/components/ui/card';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
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 { 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 { formatDateTime } from '@shared/utils/dateFormat';
|
|
import { getDarkVesselPatternIntent, getDarkVesselPatternLabel, getDarkVesselPatternMeta } from '@shared/constants/darkVesselPatterns';
|
|
import { getVesselSurveillanceIntent, getVesselSurveillanceLabel } from '@shared/constants/vesselAnalysisStatuses';
|
|
import { useSettingsStore } from '@stores/settingsStore';
|
|
|
|
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */
|
|
|
|
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 '정상';
|
|
}
|
|
|
|
function deriveFlag(mmsi: string): string {
|
|
if (mmsi.startsWith('412')) return '중국';
|
|
if (mmsi.startsWith('440') || mmsi.startsWith('441')) return '한국';
|
|
return '미상';
|
|
}
|
|
|
|
function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect {
|
|
const risk = item.algorithms.riskScore.score;
|
|
const status = deriveStatus(item);
|
|
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,
|
|
};
|
|
}
|
|
|
|
export function DarkVesselDetection() {
|
|
const { t } = useTranslation('detection');
|
|
const { t: tc } = useTranslation('common');
|
|
const lang = useSettingsStore((s) => s.language);
|
|
|
|
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 => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
|
{ key: 'flag', label: '국적', width: '50px' },
|
|
{ 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]);
|
|
|
|
const [darkItems, setDarkItems] = useState<VesselAnalysisItem[]>([]);
|
|
const [serviceAvailable, setServiceAvailable] = useState(true);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const res = await fetchVesselAnalysis();
|
|
setServiceAvailable(res.serviceAvailable);
|
|
setDarkItems(filterDarkVessels(res.items));
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
|
|
setServiceAvailable(false);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { loadData(); }, [loadData]);
|
|
|
|
const DATA: Suspect[] = useMemo(
|
|
() => darkItems.map((item, i) => mapItemToSuspect(item, i)),
|
|
[darkItems],
|
|
);
|
|
|
|
const avgRisk = useMemo(
|
|
() => DATA.length > 0 ? Math.round(DATA.reduce((s, d) => s + d.risk, 0) / DATA.length) : 0,
|
|
[DATA],
|
|
);
|
|
|
|
const mapRef = useRef<MapHandle>(null);
|
|
|
|
const buildLayers = useCallback(() => [
|
|
...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',
|
|
})),
|
|
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,
|
|
label: `${d.id} ${d.name}`,
|
|
} as MarkerData)),
|
|
),
|
|
], [DATA]);
|
|
|
|
useMapLayers(mapRef, buildLayers, [DATA]);
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
icon={EyeOff}
|
|
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>
|
|
</div>
|
|
)}
|
|
|
|
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
|
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
</div>
|
|
)}
|
|
|
|
<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>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="선박유형, MMSI, 패턴 검색..." searchKeys={['name', 'mmsi', 'pattern', 'flag']} 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" />
|
|
{/* 범례 */}
|
|
<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="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>
|
|
</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>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</PageContainer>
|
|
);
|
|
}
|