feat: S5 프론트 나머지 화면 실데이터 전환 — 탐지/함정/단속계획
탐지 화면 3개: - GearDetection: gearStore 더미 → fetchGroups() API (GEAR_IN/OUT_ZONE) - DarkVesselDetection: vesselStore 더미 → fetchVesselAnalysis() + filterDarkVessels() - 패턴 자동 분류 (완전차단/장기소실/MMSI변조/간헐송출) - ChinaFishing: inline 더미 → fetchVesselAnalysis() + mmsi 412* 필터 - 센서 카운터 동적 계산, 위험도 분포 도넛 차트 함정/단속계획: - patrol.ts: 스텁 → GET /api/patrol-ships 실제 호출 - patrolStore: API 기반 (routes/scenarios는 mock 유지) - EnforcementPlan: GET /api/enforcement/plans 연결 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
4e6ac8645a
커밋
c17d190e1d
@ -1,32 +1,31 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import {
|
||||
Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud,
|
||||
Eye, AlertTriangle, ShieldCheck, Radio, Anchor, RotateCcw,
|
||||
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon
|
||||
Eye, AlertTriangle, Radio, RotateCcw,
|
||||
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
|
||||
} from 'lucide-react';
|
||||
import { GearIdentification } from './GearIdentification';
|
||||
import { RealAllVessels } from './RealVesselAnalysis';
|
||||
import { BaseChart, PieChart as EcPieChart } from '@lib/charts';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import { PieChart as EcPieChart } from '@lib/charts';
|
||||
import { useTransferStore } from '@stores/transferStore';
|
||||
import {
|
||||
fetchVesselAnalysis,
|
||||
filterDarkVessels,
|
||||
filterTransshipSuspects,
|
||||
type VesselAnalysisItem,
|
||||
type VesselAnalysisStats,
|
||||
} from '@/services/vesselAnalysisApi';
|
||||
|
||||
// ─── 센서 카운터 (시안 2행) ─────────────
|
||||
const COUNTERS_ROW1 = [
|
||||
{ label: '통합', count: 1350, color: '#6b7280', icon: '🔵' },
|
||||
{ label: 'AIS', count: 2212, color: '#3b82f6', icon: '🟢' },
|
||||
{ label: 'E-Nav', count: 745, color: '#8b5cf6', icon: '🔷' },
|
||||
{ label: '여객선', count: 1, color: '#10b981', icon: '🟡' },
|
||||
];
|
||||
const COUNTERS_ROW2 = [
|
||||
{ label: '중국어선', count: 20, color: '#f97316', icon: '🟠' },
|
||||
{ label: 'V-PASS', count: 465, color: '#06b6d4', icon: '🟢' },
|
||||
{ label: '함정', count: 2, color: '#6b7280', icon: '🔵' },
|
||||
{ label: '위험물', count: 0, color: '#6b7280', icon: '⚪' },
|
||||
];
|
||||
// ─── 중국 MMSI prefix ─────────────
|
||||
const CHINA_MMSI_PREFIX = '412';
|
||||
|
||||
// ─── 특이운항 선박 리스트 ────────────────
|
||||
function isChinaVessel(mmsi: string): boolean {
|
||||
return mmsi.startsWith(CHINA_MMSI_PREFIX);
|
||||
}
|
||||
|
||||
// ─── 특이운항 선박 리스트 타입 ────────────────
|
||||
type VesselStatus = '의심' | '양호' | '경고';
|
||||
interface VesselItem {
|
||||
id: string;
|
||||
@ -41,30 +40,27 @@ interface VesselItem {
|
||||
riskPct: number;
|
||||
}
|
||||
|
||||
const VESSEL_LIST: VesselItem[] = [
|
||||
{ id: '1', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 44 },
|
||||
{ id: '2', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '양호', riskPct: 70 },
|
||||
{ id: '3', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 24 },
|
||||
{ id: '4', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '경고', riskPct: 84 },
|
||||
{ id: '5', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 44 },
|
||||
{ id: '6', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 44 },
|
||||
];
|
||||
function deriveVesselStatus(score: number): VesselStatus {
|
||||
if (score >= 70) return '경고';
|
||||
if (score >= 40) return '의심';
|
||||
return '양호';
|
||||
}
|
||||
|
||||
// ─── 월별 불법조업 통계 ──────────────────
|
||||
const MONTHLY_DATA = [
|
||||
{ month: 'JAN', 범장망: 45, 쌍끌이: 30, 외끌이: 20, 트롤: 10 },
|
||||
{ month: 'FEB', 범장망: 55, 쌍끌이: 35, 외끌이: 25, 트롤: 15 },
|
||||
{ month: 'MAR', 범장망: 70, 쌍끌이: 45, 외끌이: 30, 트롤: 20 },
|
||||
{ month: 'APR', 범장망: 85, 쌍끌이: 50, 외끌이: 35, 트롤: 25 },
|
||||
{ month: 'MAY', 범장망: 95, 쌍끌이: 55, 외끌이: 40, 트롤: 30 },
|
||||
{ month: 'JUN', 범장망: 80, 쌍끌이: 45, 외끌이: 35, 트롤: 22 },
|
||||
{ month: 'JUL', 범장망: 60, 쌍끌이: 35, 외끌이: 25, 트롤: 18 },
|
||||
{ month: 'AUG', 범장망: 50, 쌍끌이: 30, 외끌이: 20, 트롤: 12 },
|
||||
{ month: 'SEP', 범장망: 65, 쌍끌이: 40, 외끌이: 28, 트롤: 20 },
|
||||
{ month: 'OCT', 범장망: 75, 쌍끌이: 48, 외끌이: 32, 트롤: 22 },
|
||||
{ month: 'NOV', 범장망: 90, 쌍끌이: 52, 외끌이: 38, 트롤: 28 },
|
||||
{ month: 'DEC', 범장망: 100, 쌍끌이: 60, 외끌이: 42, 트롤: 30 },
|
||||
];
|
||||
function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem {
|
||||
const score = item.algorithms.riskScore.score;
|
||||
return {
|
||||
id: String(idx + 1),
|
||||
mmsi: item.mmsi,
|
||||
callSign: '-',
|
||||
channel: '',
|
||||
source: 'AIS',
|
||||
name: item.classification.vesselType || item.mmsi,
|
||||
type: item.classification.fishingPct > 0.5 ? 'Fishing' : 'Cargo',
|
||||
country: 'China',
|
||||
status: deriveVesselStatus(score),
|
||||
riskPct: score,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── VTS 연계 항목 ─────────────────────
|
||||
const VTS_ITEMS = [
|
||||
@ -299,6 +295,81 @@ export function ChinaFishing() {
|
||||
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
|
||||
const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계');
|
||||
|
||||
// API state
|
||||
const [allItems, setAllItems] = useState<VesselAnalysisItem[]>([]);
|
||||
const [apiStats, setApiStats] = useState<VesselAnalysisStats | null>(null);
|
||||
const [serviceAvailable, setServiceAvailable] = useState(true);
|
||||
const [apiLoading, setApiLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState('');
|
||||
|
||||
const loadApi = useCallback(async () => {
|
||||
setApiLoading(true);
|
||||
setApiError('');
|
||||
try {
|
||||
const res = await fetchVesselAnalysis();
|
||||
setServiceAvailable(res.serviceAvailable);
|
||||
setAllItems(res.items);
|
||||
setApiStats(res.stats);
|
||||
} catch (e: unknown) {
|
||||
setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
|
||||
setServiceAvailable(false);
|
||||
} finally {
|
||||
setApiLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadApi(); }, [loadApi]);
|
||||
|
||||
// 중국어선 필터
|
||||
const chinaVessels = useMemo(
|
||||
() => allItems.filter((i) => isChinaVessel(i.mmsi)),
|
||||
[allItems],
|
||||
);
|
||||
|
||||
const chinaDark = useMemo(() => filterDarkVessels(chinaVessels), [chinaVessels]);
|
||||
const chinaTransship = useMemo(() => filterTransshipSuspects(chinaVessels), [chinaVessels]);
|
||||
|
||||
// 센서 카운터 (API 기반)
|
||||
const countersRow1 = useMemo(() => [
|
||||
{ label: '통합', count: allItems.length, color: '#6b7280' },
|
||||
{ label: 'AIS', count: allItems.length, color: '#3b82f6' },
|
||||
{ label: 'EEZ 내', count: allItems.filter((i) => i.algorithms.location.zone !== 'EEZ_OR_BEYOND').length, color: '#8b5cf6' },
|
||||
{ label: '어업선', count: allItems.filter((i) => i.classification.fishingPct > 0.5).length, color: '#10b981' },
|
||||
], [allItems]);
|
||||
|
||||
const countersRow2 = useMemo(() => [
|
||||
{ label: '중국어선', count: chinaVessels.length, color: '#f97316' },
|
||||
{ label: 'Dark Vessel', count: chinaDark.length, color: '#ef4444' },
|
||||
{ label: '환적 의심', count: chinaTransship.length, color: '#06b6d4' },
|
||||
{ label: '고위험', count: chinaVessels.filter((i) => i.algorithms.riskScore.score >= 70).length, color: '#ef4444' },
|
||||
], [chinaVessels, chinaDark, chinaTransship]);
|
||||
|
||||
// 특이운항 선박 리스트 (중국어선 중 riskScore >= 40)
|
||||
const vesselList: VesselItem[] = useMemo(
|
||||
() => chinaVessels
|
||||
.filter((i) => i.algorithms.riskScore.score >= 40)
|
||||
.sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score)
|
||||
.slice(0, 20)
|
||||
.map((item, idx) => mapToVesselItem(item, idx)),
|
||||
[chinaVessels],
|
||||
);
|
||||
|
||||
// 위험도별 분포 (도넛 차트용)
|
||||
const riskDistribution = useMemo(() => {
|
||||
const critical = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length;
|
||||
const high = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'HIGH').length;
|
||||
const medium = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length;
|
||||
const low = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'LOW').length;
|
||||
return { critical, high, medium, low, total: chinaVessels.length };
|
||||
}, [chinaVessels]);
|
||||
|
||||
// 안전도 지수 계산
|
||||
const safetyIndex = useMemo(() => {
|
||||
if (chinaVessels.length === 0) return { risk: 0, safety: 100 };
|
||||
const avgRisk = chinaVessels.reduce((s, i) => s + i.algorithms.riskScore.score, 0) / chinaVessels.length;
|
||||
return { risk: Number((avgRisk / 10).toFixed(2)), safety: Number(((100 - avgRisk) / 10).toFixed(2)) };
|
||||
}, [chinaVessels]);
|
||||
|
||||
const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const;
|
||||
const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const;
|
||||
|
||||
@ -337,6 +408,21 @@ export function ChinaFishing() {
|
||||
{/* AI 대시보드 모드 */}
|
||||
{mode === 'dashboard' && <>
|
||||
|
||||
{!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>
|
||||
)}
|
||||
|
||||
{apiError && <div className="text-xs text-red-400">에러: {apiError}</div>}
|
||||
|
||||
{apiLoading && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* iran 백엔드 실시간 분석 결과 */}
|
||||
<RealAllVessels />
|
||||
|
||||
@ -344,9 +430,9 @@ export function ChinaFishing() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 bg-surface-overlay rounded-lg px-3 py-1.5 border border-slate-700/40">
|
||||
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] text-label">기준 : 2023-09-25 14:56</span>
|
||||
<span className="text-[11px] text-label">기준 : {new Date().toLocaleString('ko-KR')}</span>
|
||||
</div>
|
||||
<button className="p-1.5 rounded-lg bg-surface-overlay border border-slate-700/40 text-muted-foreground hover:text-heading transition-colors">
|
||||
<button type="button" onClick={loadApi} className="p-1.5 rounded-lg bg-surface-overlay border border-slate-700/40 text-muted-foreground hover:text-heading transition-colors" title="새로고침">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5">
|
||||
@ -368,20 +454,22 @@ export function ChinaFishing() {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-bold text-heading">해역별 통항량</span>
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
<span className="text-hint">해구번호</span>
|
||||
<span className="text-heading font-bold font-mono">123-456</span>
|
||||
</div>
|
||||
{apiStats && (
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
<span className="text-hint">분석 대상</span>
|
||||
<span className="text-heading font-bold font-mono">{apiStats.total.toLocaleString()}척</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground">
|
||||
<span>해역 전체 통항량</span>
|
||||
<span className="text-lg font-extrabold text-heading">12,454</span>
|
||||
<span className="text-lg font-extrabold text-heading">{allItems.length.toLocaleString()}</span>
|
||||
<span className="text-hint">(척)</span>
|
||||
</div>
|
||||
|
||||
{/* 카운터 Row 1 */}
|
||||
<div className="grid grid-cols-4 gap-2 mb-2">
|
||||
{COUNTERS_ROW1.map((c) => (
|
||||
{countersRow1.map((c) => (
|
||||
<div key={c.label} className="bg-surface-overlay rounded-lg p-2.5 text-center border border-slate-700/30">
|
||||
<div className="text-[9px] text-hint mb-1">{c.label}</div>
|
||||
<div className="text-lg font-extrabold text-heading font-mono">{c.count.toLocaleString()}</div>
|
||||
@ -390,10 +478,10 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
{/* 카운터 Row 2 */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{COUNTERS_ROW2.map((c) => (
|
||||
{countersRow2.map((c) => (
|
||||
<div key={c.label} className="bg-surface-overlay rounded-lg p-2.5 text-center border border-slate-700/30">
|
||||
<div className="text-[9px] text-hint mb-1">{c.label}</div>
|
||||
<div className="text-lg font-extrabold font-mono" style={{ color: c.count > 0 ? '#e5e7eb' : '#4b5563' }}>
|
||||
<div className={`text-lg font-extrabold font-mono ${c.count > 0 ? 'text-heading' : 'text-muted'}`}>
|
||||
{c.count > 0 ? c.count.toLocaleString() : '-'}
|
||||
</div>
|
||||
</div>
|
||||
@ -413,13 +501,13 @@ export function ChinaFishing() {
|
||||
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
||||
<span className="text-orange-400 font-medium">종합</span> 위험지수
|
||||
</div>
|
||||
<SemiGauge value={5.21} label="" color="#f97316" />
|
||||
<SemiGauge value={safetyIndex.risk} label="" color="#f97316" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
||||
종합 <span className="text-blue-400 font-medium">안전지수</span>
|
||||
</div>
|
||||
<SemiGauge value={5.21} label="" color="#3b82f6" />
|
||||
<SemiGauge value={safetyIndex.safety} label="" color="#3b82f6" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -457,7 +545,7 @@ export function ChinaFishing() {
|
||||
<span className="text-green-400 font-bold ml-auto">정상</span>
|
||||
</div>
|
||||
</div>
|
||||
<CircleGauge value={90.2} label="" />
|
||||
<CircleGauge value={chinaVessels.length > 0 ? Number(((1 - riskDistribution.critical / Math.max(chinaVessels.length, 1)) * 100).toFixed(1)) : 100} label="" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -490,7 +578,12 @@ export function ChinaFishing() {
|
||||
|
||||
{/* 선박 목록 */}
|
||||
<div className="max-h-[420px] overflow-y-auto">
|
||||
{VESSEL_LIST.map((v) => (
|
||||
{vesselList.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-hint text-xs">
|
||||
{apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'}
|
||||
</div>
|
||||
)}
|
||||
{vesselList.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 hover:bg-surface-overlay transition-colors cursor-pointer group"
|
||||
@ -498,8 +591,7 @@ export function ChinaFishing() {
|
||||
<StatusRing status={v.status} riskPct={v.riskPct} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint mb-0.5">
|
||||
<span>ID | <span className="text-label">{v.mmsi}</span></span>
|
||||
<span>호출부호 | <span className="text-label">{v.callSign}</span></span>
|
||||
<span>MMSI | <span className="text-label">{v.mmsi}</span></span>
|
||||
<span>출처 | <span className="text-label">{v.source}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -507,7 +599,6 @@ export function ChinaFishing() {
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-[8px] px-1.5 py-0">{v.type}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
|
||||
<span>🇰🇷</span>
|
||||
<span>{v.country}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -543,75 +634,45 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex gap-4">
|
||||
{/* 바 차트 */}
|
||||
<div className="flex-1">
|
||||
<BaseChart height={220} option={{
|
||||
grid: { top: 10, right: 10, bottom: 24, left: 36, containLabel: false },
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: MONTHLY_DATA.map(d => d.month) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '범장망', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.범장망), itemStyle: { color: '#22c55e' } },
|
||||
{ name: '쌍끌이', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.쌍끌이), itemStyle: { color: '#f97316' } },
|
||||
{ name: '외끌이', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.외끌이), itemStyle: { color: '#60a5fa' } },
|
||||
{ name: '트롤', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.트롤), itemStyle: { color: '#6b7280', borderRadius: [2, 2, 0, 0] } },
|
||||
],
|
||||
} as EChartsOption} />
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center justify-center gap-4 mt-2">
|
||||
{[
|
||||
{ label: '범장망 선박', color: '#22c55e' },
|
||||
{ label: '쌍끌이 선박', color: '#f97316' },
|
||||
{ label: '외끌이 선박', color: '#60a5fa' },
|
||||
{ label: '트롤 선박', color: '#6b7280' },
|
||||
].map((l) => (
|
||||
<span key={l.label} className="flex items-center gap-1 text-[9px] text-muted-foreground">
|
||||
<span className="w-2 h-2 rounded-full" style={{ background: l.color }} />
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
{/* 월별 통계 - API 미지원, 준비중 안내 */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center py-8">
|
||||
<div className="text-muted-foreground text-xs mb-2">월별 불법조업 통계</div>
|
||||
<div className="text-hint text-[10px] bg-surface-overlay rounded-lg px-4 py-3 border border-border">
|
||||
월별 집계 API 연동 준비중입니다. 실시간 위험도 분포는 우측 도넛을 참고하세요.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 도넛 2개 */}
|
||||
<div className="flex flex-col items-center justify-center gap-3 w-24">
|
||||
{/* 위험도 분포 도넛 */}
|
||||
<div className="flex flex-col items-center justify-center gap-3 w-28">
|
||||
<div className="relative w-[80px] h-[80px]">
|
||||
<EcPieChart
|
||||
data={[
|
||||
{ name: 'active', value: 70, color: '#22c55e' },
|
||||
{ name: 'rest', value: 30, color: '#1e293b' },
|
||||
{ name: 'CRITICAL', value: riskDistribution.critical || 1, color: '#ef4444' },
|
||||
{ name: 'HIGH', value: riskDistribution.high || 1, color: '#f97316' },
|
||||
{ name: 'MEDIUM', value: riskDistribution.medium || 1, color: '#eab308' },
|
||||
{ name: 'LOW', value: riskDistribution.low || 1, color: '#3b82f6' },
|
||||
]}
|
||||
height={80}
|
||||
innerRadius={24}
|
||||
outerRadius={34}
|
||||
/>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span className="text-sm font-extrabold text-heading">356</span>
|
||||
<span className="text-[7px] text-hint">TOTAL</span>
|
||||
<span className="text-sm font-extrabold text-heading">{riskDistribution.total}</span>
|
||||
<span className="text-[7px] text-hint">중국어선</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-[80px] h-[80px]">
|
||||
<EcPieChart
|
||||
data={[
|
||||
{ name: 'active', value: 60, color: '#22c55e' },
|
||||
{ name: 'rest', value: 40, color: '#1e293b' },
|
||||
]}
|
||||
height={80}
|
||||
innerRadius={24}
|
||||
outerRadius={34}
|
||||
/>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span className="text-sm font-extrabold text-heading">356</span>
|
||||
<span className="text-[7px] text-hint">TOTAL</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 text-[8px]">
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-red-500" /><span className="text-hint">CRITICAL {riskDistribution.critical}</span></div>
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-orange-500" /><span className="text-hint">HIGH {riskDistribution.high}</span></div>
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-yellow-500" /><span className="text-hint">MEDIUM {riskDistribution.medium}</span></div>
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-blue-500" /><span className="text-hint">LOW {riskDistribution.low}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 다운로드 버튼 */}
|
||||
<div className="px-4 pb-3 flex justify-end">
|
||||
<button className="px-3 py-1 bg-secondary border border-slate-700/50 rounded text-[10px] text-label hover:bg-switch-background transition-colors">
|
||||
<button type="button" className="px-3 py-1 bg-secondary border border-slate-700/50 rounded text-[10px] text-label hover:bg-switch-background transition-colors">
|
||||
다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -1,38 +1,77 @@
|
||||
import { useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
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 { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { Eye, EyeOff, AlertTriangle, Ship, Radar, Radio, Target, Shield, Tag } from 'lucide-react';
|
||||
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 { useVesselStore } from '@stores/vesselStore';
|
||||
import { RealDarkVessels, RealSpoofingVessels } from './RealVesselAnalysis';
|
||||
import {
|
||||
fetchVesselAnalysis,
|
||||
filterDarkVessels,
|
||||
type VesselAnalysisItem,
|
||||
} from '@/services/vesselAnalysisApi';
|
||||
|
||||
/* 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 FLAG_MAP: Record<string, string> = { CN: '중국', KR: '한국', 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: item.timestamp ? new Date(item.timestamp).toLocaleString('ko-KR') : '-',
|
||||
status,
|
||||
label: risk >= 90 ? (status === '추적중' ? '불법' : '-') : status === '정상' ? '정상' : '-',
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const PATTERN_COLORS: Record<string, string> = {
|
||||
'AIS 완전차단': '#ef4444',
|
||||
'MMSI 3회 변경': '#f97316',
|
||||
'급격 속력변화': '#eab308',
|
||||
'MMSI 변조 의심': '#f97316',
|
||||
'장기소실': '#eab308',
|
||||
'신호 간헐송출': '#a855f7',
|
||||
'비정기 신호': '#3b82f6',
|
||||
'국적 위장 의심': '#ec4899',
|
||||
};
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
'추적중': '#ef4444',
|
||||
'감시중': '#eab308',
|
||||
'확인중': '#3b82f6',
|
||||
'정상': '#22c55e',
|
||||
};
|
||||
|
||||
const cols: DataColumn<Suspect>[] = [
|
||||
{ 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 className="bg-red-500/15 text-red-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||
{ key: 'name', label: '선박명', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</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 => <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,
|
||||
@ -46,33 +85,42 @@ const cols: DataColumn<Suspect>[] = [
|
||||
|
||||
export function DarkVesselDetection() {
|
||||
const { t } = useTranslation('detection');
|
||||
const { suspects, loaded, load } = useVesselStore();
|
||||
useEffect(() => { if (!loaded) load(); }, [loaded, load]);
|
||||
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]);
|
||||
|
||||
// Map VesselData to local Suspect shape
|
||||
const DATA: Suspect[] = useMemo(
|
||||
() =>
|
||||
suspects.map((v) => ({
|
||||
id: v.id,
|
||||
mmsi: v.mmsi,
|
||||
name: v.name,
|
||||
flag: FLAG_MAP[v.flag] ?? v.flag,
|
||||
pattern: v.pattern ?? '-',
|
||||
risk: v.risk,
|
||||
lastAIS: v.lastSignal ?? '-',
|
||||
status: v.status,
|
||||
label: v.risk >= 90 ? (v.status === '추적중' ? '불법' : '-') : v.status === '정상' ? '정상' : '-',
|
||||
lat: v.lat,
|
||||
lng: v.lng,
|
||||
})),
|
||||
[suspects],
|
||||
() => 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 => ({
|
||||
@ -83,7 +131,6 @@ export function DarkVesselDetection() {
|
||||
})),
|
||||
0.08,
|
||||
),
|
||||
// 탐지 선박 마커
|
||||
createMarkerLayer(
|
||||
'dv-markers',
|
||||
DATA.map(d => ({
|
||||
@ -106,22 +153,36 @@ export function DarkVesselDetection() {
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('darkVessel.desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!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: '의심 선박', v: DATA.filter(d => d.risk > 50).length, c: 'text-red-400', i: AlertTriangle },
|
||||
{ l: 'Dark Vessel', v: DATA.filter(d => d.pattern.includes('차단')).length, c: 'text-orange-400', i: EyeOff },
|
||||
{ l: 'MMSI 변조', v: DATA.filter(d => d.pattern.includes('MMSI')).length, c: 'text-yellow-400', i: Radio },
|
||||
{ l: '라벨링 완료', v: DATA.filter(d => d.label !== '-').length + '/' + DATA.length, c: 'text-cyan-400', i: Tag },
|
||||
{[
|
||||
{ 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>
|
||||
{/* iran 백엔드 실시간 Dark Vessel + GPS 스푸핑 */}
|
||||
<RealDarkVessels />
|
||||
<RealSpoofingVessels />
|
||||
|
||||
<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', 'pattern', 'flag']} exportFilename="Dark_Vessel_탐지" />
|
||||
|
||||
{/* 탐지 위치 지도 */}
|
||||
<Card>
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
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 { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { Anchor, MapPin, AlertTriangle, CheckCircle, Clock, Ship, Filter } from 'lucide-react';
|
||||
import { Anchor, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||
import type { MarkerData } from '@lib/map';
|
||||
import { useGearStore } from '@stores/gearStore';
|
||||
import { RealGearGroups } from './RealGearGroups';
|
||||
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
||||
|
||||
/* SFR-10: 불법 어망·어구 탐지 및 관리 */
|
||||
|
||||
@ -19,14 +18,36 @@ const RISK_COLORS: Record<string, string> = {
|
||||
'안전': '#22c55e',
|
||||
};
|
||||
|
||||
const GEAR_ICONS: Record<string, string> = {
|
||||
'저층트롤': '🔴',
|
||||
'유자망': '🟠',
|
||||
'유자망(대형)': '🔴',
|
||||
'통발': '🟢',
|
||||
'선망': '🟡',
|
||||
'연승': '🔵',
|
||||
};
|
||||
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: g.snapshotTime ? new Date(g.snapshotTime).toLocaleDateString('ko-KR') : '-',
|
||||
lastSignal: g.snapshotTime ? new Date(g.snapshotTime).toLocaleTimeString('ko-KR') : '-',
|
||||
risk,
|
||||
lat: g.centerLat,
|
||||
lng: g.centerLon,
|
||||
};
|
||||
}
|
||||
|
||||
const cols: DataColumn<Gear>[] = [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
@ -44,17 +65,39 @@ const cols: DataColumn<Gear>[] = [
|
||||
|
||||
export function GearDetection() {
|
||||
const { t } = useTranslation('detection');
|
||||
const { items, loaded, load } = useGearStore();
|
||||
useEffect(() => { if (!loaded) load(); }, [loaded, load]);
|
||||
const [groups, setGroups] = useState<GearGroupItem[]>([]);
|
||||
const [serviceAvailable, setServiceAvailable] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// GearRecord from the store matches the local Gear shape exactly
|
||||
const DATA: Gear[] = items as unknown as Gear[];
|
||||
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(() => [
|
||||
...STATIC_LAYERS,
|
||||
// 어구 설치 영역 (고위험만)
|
||||
createRadiusLayer(
|
||||
'gear-radius',
|
||||
DATA.filter(g => g.risk === '고위험').map(g => ({
|
||||
@ -65,7 +108,6 @@ export function GearDetection() {
|
||||
})),
|
||||
0.1,
|
||||
),
|
||||
// 어구 마커
|
||||
createMarkerLayer(
|
||||
'gear-markers',
|
||||
DATA.map(g => ({
|
||||
@ -86,15 +128,36 @@ export function GearDetection() {
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Anchor className="w-5 h-5 text-orange-400" />{t('gearDetection.title')}</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('gearDetection.desc')}</p>
|
||||
</div>
|
||||
|
||||
{!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 => (
|
||||
{[
|
||||
{ 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>
|
||||
{/* iran 백엔드 실시간 어구/선단 그룹 */}
|
||||
<RealGearGroups />
|
||||
|
||||
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="어구유형, 소유선박, 해역 검색..." searchKeys={['type', 'owner', 'zone']} exportFilename="어구탐지" />
|
||||
|
||||
|
||||
@ -1,17 +1,33 @@
|
||||
import { useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, 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 { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { Shield, AlertTriangle, Clock, MapPin, Ship, Bell, Plus, Target, Calendar, Users } from 'lucide-react';
|
||||
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users } from 'lucide-react';
|
||||
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||
import type { MarkerData } from '@lib/map';
|
||||
import { useEnforcementStore } from '@stores/enforcementStore';
|
||||
import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement';
|
||||
|
||||
/* SFR-06: 단속 계획·경보 연계(단속 우선지역 예보) */
|
||||
|
||||
interface Plan { id: string; zone: string; lat: number; lng: number; risk: number; period: string; ships: string; crew: number; status: string; alert: string; [key: string]: unknown; }
|
||||
|
||||
/** API 응답 → 화면용 Plan 변환 */
|
||||
function toPlan(p: EnforcementPlanApi): Plan {
|
||||
return {
|
||||
id: p.planUid,
|
||||
zone: p.areaName ?? p.zoneCode ?? '-',
|
||||
lat: p.lat ?? 0,
|
||||
lng: p.lon ?? 0,
|
||||
risk: p.riskScore ?? 0,
|
||||
period: p.plannedDate,
|
||||
ships: `${p.assignedShipCount}척`,
|
||||
crew: p.assignedCrew,
|
||||
status: p.status,
|
||||
alert: p.alertStatus ?? '-',
|
||||
};
|
||||
}
|
||||
|
||||
const cols: DataColumn<Plan>[] = [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'zone', label: '단속 구역', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
||||
@ -21,20 +37,38 @@ const cols: DataColumn<Plan>[] = [
|
||||
{ key: 'ships', label: '참여 함정', render: v => <span className="text-cyan-400">{v as string}</span> },
|
||||
{ key: 'crew', label: '인력', width: '50px', align: 'right', render: v => <span className="text-heading font-bold">{v as number || '-'}</span> },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const s = v as string; return <Badge className={`border-0 text-[9px] ${s === '확정' ? 'bg-green-500/20 text-green-400' : s === '계획중' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground'}`}>{s}</Badge>; } },
|
||||
render: v => { const s = v as string; return <Badge className={`border-0 text-[9px] ${s === '확정' || s === 'CONFIRMED' ? 'bg-green-500/20 text-green-400' : s === '계획중' || s === 'PLANNED' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground'}`}>{s}</Badge>; } },
|
||||
{ key: 'alert', label: '경보', width: '80px', align: 'center',
|
||||
render: v => { const a = v as string; return a === '경보 발령' ? <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } },
|
||||
render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } },
|
||||
];
|
||||
|
||||
export function EnforcementPlan() {
|
||||
const { t } = useTranslation('enforcement');
|
||||
const { plans: storePlans, load } = useEnforcementStore();
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const PLANS: Plan[] = useMemo(
|
||||
() => storePlans.map((p) => ({ ...p } as Plan)),
|
||||
[storePlans],
|
||||
);
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getEnforcementPlans({ size: 100 })
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setPlans(res.content.map(toPlan));
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const PLANS = plans;
|
||||
|
||||
const mapRef = useRef<MapHandle>(null);
|
||||
|
||||
@ -42,7 +76,7 @@ export function EnforcementPlan() {
|
||||
...STATIC_LAYERS,
|
||||
createRadiusLayer(
|
||||
'ep-radius-confirmed',
|
||||
PLANS.filter(p => p.status === '확정').map(p => ({
|
||||
PLANS.filter(p => p.status === '확정' || p.status === 'CONFIRMED').map(p => ({
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
radius: 20000,
|
||||
@ -52,7 +86,7 @@ export function EnforcementPlan() {
|
||||
),
|
||||
createRadiusLayer(
|
||||
'ep-radius-planned',
|
||||
PLANS.filter(p => p.status !== '확정').map(p => ({
|
||||
PLANS.filter(p => p.status !== '확정' && p.status !== 'CONFIRMED').map(p => ({
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
radius: 20000,
|
||||
@ -74,6 +108,15 @@ export function EnforcementPlan() {
|
||||
|
||||
useMapLayers(mapRef, buildLayers, [PLANS]);
|
||||
|
||||
// 통계 요약값
|
||||
const todayCount = PLANS.length;
|
||||
const alertCount = PLANS.filter(p => p.alert === '경보 발령' || p.alert === 'ALERT').length;
|
||||
const totalShips = PLANS.reduce((sum, p) => {
|
||||
const num = parseInt(p.ships, 10);
|
||||
return sum + (isNaN(num) ? 0 : num);
|
||||
}, 0);
|
||||
const totalCrew = PLANS.reduce((sum, p) => sum + p.crew, 0);
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -83,8 +126,22 @@ export function EnforcementPlan() {
|
||||
</div>
|
||||
<button className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold rounded-lg"><Plus className="w-3.5 h-3.5" />단속 계획 수립</button>
|
||||
</div>
|
||||
|
||||
{/* 로딩/에러 상태 */}
|
||||
{loading && (
|
||||
<div className="text-center text-muted-foreground text-sm py-4">단속 계획을 불러오는 중...</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-center text-red-400 text-sm py-4">로드 실패: {error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{[{ l: '오늘 계획', v: '3건', c: 'text-heading', i: Calendar }, { l: '경보 발령', v: '1건', c: 'text-red-400', i: AlertTriangle }, { l: '투입 함정', v: '4척', c: 'text-cyan-400', i: Ship }, { l: '투입 인력', v: '90명', c: 'text-green-400', i: Users }].map(k => (
|
||||
{[
|
||||
{ l: '오늘 계획', v: `${todayCount}건`, c: 'text-heading', i: Calendar },
|
||||
{ l: '경보 발령', v: `${alertCount}건`, c: 'text-red-400', i: AlertTriangle },
|
||||
{ l: '투입 함정', v: `${totalShips}척`, c: 'text-cyan-400', i: Ship },
|
||||
{ l: '투입 인력', v: `${totalCrew}명`, c: 'text-green-400', i: Users },
|
||||
].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>
|
||||
|
||||
@ -4,7 +4,8 @@ export { getEvents, getEventById, ackEvent, updateEventStatus, getEventStats } f
|
||||
export type { PredictionEvent, EventPageResponse, EventStats } from './event';
|
||||
export { getEnforcementRecords, createEnforcementRecord, getEnforcementPlans } from './enforcement';
|
||||
export type { EnforcementRecord, EnforcementPlan } from './enforcement';
|
||||
export { getPatrolShips } from './patrol';
|
||||
export { getPatrolShips, updatePatrolShipStatus, toLegacyPatrolShip } from './patrol';
|
||||
export type { PatrolShipApi } from './patrol';
|
||||
export {
|
||||
getKpiMetrics,
|
||||
getMonthlyStats,
|
||||
|
||||
@ -1,10 +1,71 @@
|
||||
/**
|
||||
* <EFBFBD><EFBFBD><EFBFBD>비함정/순찰 API 서비스
|
||||
* 경비함정/순찰 API 서비스 -- 실제 백엔드 연동
|
||||
*/
|
||||
import type { PatrolShip } from '@data/mock/patrols';
|
||||
import { MOCK_PATROL_SHIPS } from '@data/mock/patrols';
|
||||
|
||||
/** TODO: GET /api/v1/patrols/ships */
|
||||
export async function getPatrolShips(): Promise<PatrolShip[]> {
|
||||
return MOCK_PATROL_SHIPS;
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
// ─── 서버 응답 타입 ───────────────────────────────
|
||||
|
||||
export interface PatrolShipApi {
|
||||
shipId: number;
|
||||
shipCode: string;
|
||||
shipName: string;
|
||||
shipClass: string;
|
||||
tonnage: number | null;
|
||||
maxSpeedKn: number | null;
|
||||
fuelCapacityL: number | null;
|
||||
basePort: string | null;
|
||||
currentStatus: string;
|
||||
currentLat: number | null;
|
||||
currentLon: number | null;
|
||||
currentZoneCode: string | null;
|
||||
fuelPct: number | null;
|
||||
crewCount: number | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// ─── API 호출 ─────────────────────────────────────
|
||||
|
||||
export async function getPatrolShips(): Promise<PatrolShipApi[]> {
|
||||
const res = await fetch(`${API_BASE}/patrol-ships`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updatePatrolShipStatus(
|
||||
id: number,
|
||||
data: {
|
||||
status: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
zoneCode?: string;
|
||||
fuelPct?: number;
|
||||
},
|
||||
): Promise<PatrolShipApi> {
|
||||
const res = await fetch(`${API_BASE}/patrol-ships/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── 하위 호환 헬퍼 (기존 PatrolShip 형식 → API 응답 매핑) ──
|
||||
|
||||
/** PatrolShipApi → PatrolShip (레거시) 변환 */
|
||||
export function toLegacyPatrolShip(s: PatrolShipApi): PatrolShip {
|
||||
return {
|
||||
id: s.shipCode,
|
||||
name: s.shipName,
|
||||
shipClass: s.shipClass,
|
||||
speed: s.maxSpeedKn ?? 0,
|
||||
status: s.currentStatus,
|
||||
lat: s.currentLat ?? 0,
|
||||
lng: s.currentLon ?? 0,
|
||||
fuel: s.fuelPct ?? 0,
|
||||
zone: s.currentZoneCode ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import type {
|
||||
PatrolScenario,
|
||||
CoverageZone,
|
||||
} from '@data/mock/patrols';
|
||||
import { getPatrolShips, toLegacyPatrolShip } from '@/services/patrol';
|
||||
|
||||
interface PatrolStore {
|
||||
ships: PatrolShip[];
|
||||
@ -14,7 +15,9 @@ interface PatrolStore {
|
||||
fleetRoutes: Record<string, [number, number][]>;
|
||||
selectedShipId: string | null;
|
||||
loaded: boolean;
|
||||
load: () => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
load: () => Promise<void>;
|
||||
selectShip: (id: string | null) => void;
|
||||
}
|
||||
|
||||
@ -26,27 +29,39 @@ export const usePatrolStore = create<PatrolStore>((set, get) => ({
|
||||
fleetRoutes: {},
|
||||
selectedShipId: null,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
load: () => {
|
||||
if (get().loaded) return;
|
||||
import('@data/mock/patrols').then(
|
||||
({
|
||||
MOCK_PATROL_SHIPS,
|
||||
MOCK_PATROL_ROUTES,
|
||||
MOCK_PATROL_SCENARIOS,
|
||||
MOCK_COVERAGE_ZONES,
|
||||
MOCK_FLEET_ROUTES,
|
||||
}) => {
|
||||
set({
|
||||
ships: MOCK_PATROL_SHIPS,
|
||||
routes: MOCK_PATROL_ROUTES,
|
||||
scenarios: MOCK_PATROL_SCENARIOS,
|
||||
coverage: MOCK_COVERAGE_ZONES,
|
||||
fleetRoutes: MOCK_FLEET_ROUTES,
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
load: async () => {
|
||||
if (get().loaded && !get().error) return;
|
||||
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
// 함정 목록은 API에서, 나머지(routes/scenarios/coverage)는 mock 유지
|
||||
const [apiShips, mockModule] = await Promise.all([
|
||||
getPatrolShips(),
|
||||
get().routes && Object.keys(get().routes).length > 0
|
||||
? Promise.resolve(null)
|
||||
: import('@data/mock/patrols').then((m) => ({
|
||||
routes: m.MOCK_PATROL_ROUTES,
|
||||
scenarios: m.MOCK_PATROL_SCENARIOS,
|
||||
coverage: m.MOCK_COVERAGE_ZONES,
|
||||
fleetRoutes: m.MOCK_FLEET_ROUTES,
|
||||
})),
|
||||
]);
|
||||
|
||||
set({
|
||||
ships: apiShips.map(toLegacyPatrolShip),
|
||||
routes: mockModule?.routes ?? get().routes,
|
||||
scenarios: mockModule?.scenarios ?? get().scenarios,
|
||||
coverage: mockModule?.coverage ?? get().coverage,
|
||||
fleetRoutes: mockModule?.fleetRoutes ?? get().fleetRoutes,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
});
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
selectShip: (id) => set({ selectedShipId: id }),
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user