kcg-ai-monitoring/frontend/src/features/detection/GearDetection.tsx
htlee d354c1ebc7 feat(frontend): 탐지 결과 운영 워크플로우 UI 구축
- 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) <noreply@anthropic.com>
2026-04-14 07:56:52 +09:00

216 lines
11 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 { Anchor, AlertTriangle, Loader2 } from 'lucide-react';
import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
import { formatDate } from '@shared/utils/dateFormat';
import { getPermitStatusIntent, getPermitStatusLabel, getGearJudgmentIntent } from '@shared/constants/permissionStatuses';
import { getAlertLevelHex } from '@shared/constants/alertLevels';
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; parentStatus: string; parentMmsi: string; confidence: string; [key: string]: unknown; };
// 한글 위험도 → AlertLevel hex 매핑
const RISK_HEX: Record<string, string> = {
'고위험': getAlertLevelHex('CRITICAL'),
'중위험': getAlertLevelHex('MEDIUM'),
'안전': '#22c55e',
};
function deriveRisk(g: GearGroupItem): string {
if (g.resolution?.status === 'REVIEW_REQUIRED') return '고위험';
if (g.resolution?.status === 'UNRESOLVED') return '중위험';
return '안전';
}
function deriveStatus(g: GearGroupItem): string {
if (g.resolution?.status === 'REVIEW_REQUIRED') return '불법 의심';
if (g.resolution?.status === 'UNRESOLVED') return '확인 중';
if (g.resolution?.status === 'MANUAL_CONFIRMED') return '정상';
return '확인 중';
}
function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
const risk = deriveRisk(g);
const status = deriveStatus(g);
return {
id: `G-${String(idx + 1).padStart(3, '0')}`,
type: g.groupLabel || (g.groupType === 'GEAR_IN_ZONE' ? '지정해역 어구' : '지정해역 외 어구'),
owner: g.members[0]?.name || g.members[0]?.mmsi || '-',
zone: g.groupType === 'GEAR_IN_ZONE' ? '지정해역' : '지정해역 외',
status,
permit: 'NONE',
installed: formatDate(g.snapshotTime),
lastSignal: g.snapshotTime ? new Date(g.snapshotTime).toLocaleTimeString('ko-KR') : '-',
risk,
lat: g.centerLat,
lng: g.centerLon,
parentStatus: g.resolution?.status ?? '-',
parentMmsi: g.resolution?.selectedParentMmsi ?? '-',
confidence: g.candidateCount != null ? `${g.candidateCount}` : '-',
};
}
export function GearDetection() {
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const cols: DataColumn<Gear>[] = useMemo(() => [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'owner', label: '소유 선박', sortable: true, render: v => <span className="text-cyan-400">{v as string}</span> },
{ key: 'zone', label: '설치 해역', width: '90px', sortable: true },
{ key: 'permit', label: '허가 상태', width: '80px', align: 'center',
render: v => <Badge intent={getPermitStatusIntent(v as string)} size="sm">{getPermitStatusLabel(v as string, tc, lang)}</Badge> },
{ key: 'status', label: '판정', width: '80px', align: 'center', sortable: true,
render: v => <Badge intent={getGearJudgmentIntent(v as string)} size="sm">{v as string}</Badge> },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } },
{ key: 'parentStatus', label: '모선 상태', width: '100px', sortable: true,
render: v => {
const s = v as string;
const intent = s === 'DIRECT_PARENT_MATCH' ? 'success' : s === 'AUTO_PROMOTED' ? 'info' : s === 'REVIEW_REQUIRED' ? 'warning' : s === 'UNRESOLVED' ? 'muted' : 'muted';
const label = s === 'DIRECT_PARENT_MATCH' ? '직접매칭' : s === 'AUTO_PROMOTED' ? '자동승격' : s === 'REVIEW_REQUIRED' ? '심사필요' : s === 'UNRESOLVED' ? '미결정' : s;
return <Badge intent={intent} size="sm">{label}</Badge>;
} },
{ key: 'parentMmsi', label: '추정 모선', width: '100px',
render: v => { const m = v as string; return m !== '-' ? <span className="text-cyan-400 font-mono text-[10px]">{m}</span> : <span className="text-hint">-</span>; } },
{ key: 'confidence', label: '후보', width: '50px', align: 'center',
render: v => <span className="font-mono text-[10px] text-label">{v as string}</span> },
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
], [tc, lang]);
const [groups, setGroups] = useState<GearGroupItem[]>([]);
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 fetchGroups();
setServiceAvailable(res.serviceAvailable);
setGroups(res.items.filter(
(i) => i.groupType === 'GEAR_IN_ZONE' || i.groupType === 'GEAR_OUT_ZONE',
));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
setServiceAvailable(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
const DATA: Gear[] = useMemo(
() => groups.map((g, i) => mapGroupToGear(g, i)),
[groups],
);
const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [
...createStaticLayers(),
createRadiusLayer(
'gear-radius',
DATA.filter(g => g.risk === '고위험').map(g => ({
lat: g.lat,
lng: g.lng,
radius: 6000,
color: RISK_HEX[g.risk] || "#64748b",
})),
0.1,
),
createMarkerLayer(
'gear-markers',
DATA.map(g => ({
lat: g.lat,
lng: g.lng,
color: RISK_HEX[g.risk] || "#64748b",
radius: g.risk === '고위험' ? 1200 : 800,
label: `${g.id} ${g.type}`,
} as MarkerData)),
),
], [DATA]);
useMapLayers(mapRef, buildLayers, [DATA]);
return (
<PageContainer>
<PageHeader
icon={Anchor}
iconColor="text-orange-400"
title={t('gearDetection.title')}
description={t('gearDetection.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 - </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: '전체 어구 그룹', v: DATA.length, c: 'text-heading' },
{ l: '불법 의심', v: DATA.filter(d => d.status.includes('불법')).length, c: 'text-red-400' },
{ l: '확인 중', v: DATA.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' },
{ l: '정상', v: DATA.filter(d => d.status === '정상').length, c: 'text-green-400' },
].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">
<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="어구유형, 소유선박, 해역 검색..." searchKeys={['type', 'owner', 'zone']} exportFilename="어구탐지" />
{/* 어구 탐지 위치 지도 */}
<Card>
<CardContent className="p-0 relative">
<BaseMap ref={mapRef} center={[36.5, 127.0]} 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">
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-red-500" /><span className="text-[8px] text-muted-foreground"> ( /)</span></div>
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-yellow-500" /><span className="text-[8px] text-muted-foreground"> ( )</span></div>
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-green-500" /><span className="text-[8px] text-muted-foreground"> ()</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">
<Anchor className="w-3.5 h-3.5 text-orange-400" />
<span className="text-[10px] text-orange-400 font-bold">{DATA.length}</span>
<span className="text-[9px] text-hint"> </span>
</div>
</CardContent>
</Card>
</PageContainer>
);
}