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 { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud,
|
Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud,
|
||||||
Eye, AlertTriangle, ShieldCheck, Radio, Anchor, RotateCcw,
|
Eye, AlertTriangle, Radio, RotateCcw,
|
||||||
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon
|
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { GearIdentification } from './GearIdentification';
|
import { GearIdentification } from './GearIdentification';
|
||||||
import { RealAllVessels } from './RealVesselAnalysis';
|
import { RealAllVessels } from './RealVesselAnalysis';
|
||||||
import { BaseChart, PieChart as EcPieChart } from '@lib/charts';
|
import { PieChart as EcPieChart } from '@lib/charts';
|
||||||
import type { EChartsOption } from 'echarts';
|
|
||||||
import { useTransferStore } from '@stores/transferStore';
|
import { useTransferStore } from '@stores/transferStore';
|
||||||
|
import {
|
||||||
|
fetchVesselAnalysis,
|
||||||
|
filterDarkVessels,
|
||||||
|
filterTransshipSuspects,
|
||||||
|
type VesselAnalysisItem,
|
||||||
|
type VesselAnalysisStats,
|
||||||
|
} from '@/services/vesselAnalysisApi';
|
||||||
|
|
||||||
// ─── 센서 카운터 (시안 2행) ─────────────
|
// ─── 중국 MMSI prefix ─────────────
|
||||||
const COUNTERS_ROW1 = [
|
const CHINA_MMSI_PREFIX = '412';
|
||||||
{ 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: '⚪' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── 특이운항 선박 리스트 ────────────────
|
function isChinaVessel(mmsi: string): boolean {
|
||||||
|
return mmsi.startsWith(CHINA_MMSI_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 특이운항 선박 리스트 타입 ────────────────
|
||||||
type VesselStatus = '의심' | '양호' | '경고';
|
type VesselStatus = '의심' | '양호' | '경고';
|
||||||
interface VesselItem {
|
interface VesselItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -41,30 +40,27 @@ interface VesselItem {
|
|||||||
riskPct: number;
|
riskPct: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VESSEL_LIST: VesselItem[] = [
|
function deriveVesselStatus(score: number): VesselStatus {
|
||||||
{ id: '1', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 44 },
|
if (score >= 70) return '경고';
|
||||||
{ id: '2', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '양호', riskPct: 70 },
|
if (score >= 40) return '의심';
|
||||||
{ id: '3', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 24 },
|
return '양호';
|
||||||
{ 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 mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem {
|
||||||
const MONTHLY_DATA = [
|
const score = item.algorithms.riskScore.score;
|
||||||
{ month: 'JAN', 범장망: 45, 쌍끌이: 30, 외끌이: 20, 트롤: 10 },
|
return {
|
||||||
{ month: 'FEB', 범장망: 55, 쌍끌이: 35, 외끌이: 25, 트롤: 15 },
|
id: String(idx + 1),
|
||||||
{ month: 'MAR', 범장망: 70, 쌍끌이: 45, 외끌이: 30, 트롤: 20 },
|
mmsi: item.mmsi,
|
||||||
{ month: 'APR', 범장망: 85, 쌍끌이: 50, 외끌이: 35, 트롤: 25 },
|
callSign: '-',
|
||||||
{ month: 'MAY', 범장망: 95, 쌍끌이: 55, 외끌이: 40, 트롤: 30 },
|
channel: '',
|
||||||
{ month: 'JUN', 범장망: 80, 쌍끌이: 45, 외끌이: 35, 트롤: 22 },
|
source: 'AIS',
|
||||||
{ month: 'JUL', 범장망: 60, 쌍끌이: 35, 외끌이: 25, 트롤: 18 },
|
name: item.classification.vesselType || item.mmsi,
|
||||||
{ month: 'AUG', 범장망: 50, 쌍끌이: 30, 외끌이: 20, 트롤: 12 },
|
type: item.classification.fishingPct > 0.5 ? 'Fishing' : 'Cargo',
|
||||||
{ month: 'SEP', 범장망: 65, 쌍끌이: 40, 외끌이: 28, 트롤: 20 },
|
country: 'China',
|
||||||
{ month: 'OCT', 범장망: 75, 쌍끌이: 48, 외끌이: 32, 트롤: 22 },
|
status: deriveVesselStatus(score),
|
||||||
{ month: 'NOV', 범장망: 90, 쌍끌이: 52, 외끌이: 38, 트롤: 28 },
|
riskPct: score,
|
||||||
{ month: 'DEC', 범장망: 100, 쌍끌이: 60, 외끌이: 42, 트롤: 30 },
|
};
|
||||||
];
|
}
|
||||||
|
|
||||||
// ─── VTS 연계 항목 ─────────────────────
|
// ─── VTS 연계 항목 ─────────────────────
|
||||||
const VTS_ITEMS = [
|
const VTS_ITEMS = [
|
||||||
@ -299,6 +295,81 @@ export function ChinaFishing() {
|
|||||||
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
|
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
|
||||||
const [statsTab, setStatsTab] = 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 vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const;
|
||||||
const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const;
|
const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const;
|
||||||
|
|
||||||
@ -337,6 +408,21 @@ export function ChinaFishing() {
|
|||||||
{/* AI 대시보드 모드 */}
|
{/* AI 대시보드 모드 */}
|
||||||
{mode === 'dashboard' && <>
|
{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 백엔드 실시간 분석 결과 */}
|
{/* iran 백엔드 실시간 분석 결과 */}
|
||||||
<RealAllVessels />
|
<RealAllVessels />
|
||||||
|
|
||||||
@ -344,9 +430,9 @@ export function ChinaFishing() {
|
|||||||
<div className="flex items-center gap-3">
|
<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">
|
<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" />
|
<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>
|
</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" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5">
|
<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">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-sm font-bold text-heading">해역별 통항량</span>
|
<span className="text-sm font-bold text-heading">해역별 통항량</span>
|
||||||
<div className="flex items-center gap-2 text-[10px]">
|
{apiStats && (
|
||||||
<span className="text-hint">해구번호</span>
|
<div className="flex items-center gap-2 text-[10px]">
|
||||||
<span className="text-heading font-bold font-mono">123-456</span>
|
<span className="text-hint">분석 대상</span>
|
||||||
</div>
|
<span className="text-heading font-bold font-mono">{apiStats.total.toLocaleString()}척</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground">
|
<div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground">
|
||||||
<span>해역 전체 통항량</span>
|
<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>
|
<span className="text-hint">(척)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카운터 Row 1 */}
|
{/* 카운터 Row 1 */}
|
||||||
<div className="grid grid-cols-4 gap-2 mb-2">
|
<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 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-[9px] text-hint mb-1">{c.label}</div>
|
||||||
<div className="text-lg font-extrabold text-heading font-mono">{c.count.toLocaleString()}</div>
|
<div className="text-lg font-extrabold text-heading font-mono">{c.count.toLocaleString()}</div>
|
||||||
@ -390,10 +478,10 @@ export function ChinaFishing() {
|
|||||||
</div>
|
</div>
|
||||||
{/* 카운터 Row 2 */}
|
{/* 카운터 Row 2 */}
|
||||||
<div className="grid grid-cols-4 gap-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 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-[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() : '-'}
|
{c.count > 0 ? c.count.toLocaleString() : '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -413,13 +501,13 @@ export function ChinaFishing() {
|
|||||||
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
||||||
<span className="text-orange-400 font-medium">종합</span> 위험지수
|
<span className="text-orange-400 font-medium">종합</span> 위험지수
|
||||||
</div>
|
</div>
|
||||||
<SemiGauge value={5.21} label="" color="#f97316" />
|
<SemiGauge value={safetyIndex.risk} label="" color="#f97316" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
||||||
종합 <span className="text-blue-400 font-medium">안전지수</span>
|
종합 <span className="text-blue-400 font-medium">안전지수</span>
|
||||||
</div>
|
</div>
|
||||||
<SemiGauge value={5.21} label="" color="#3b82f6" />
|
<SemiGauge value={safetyIndex.safety} label="" color="#3b82f6" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -457,7 +545,7 @@ export function ChinaFishing() {
|
|||||||
<span className="text-green-400 font-bold ml-auto">정상</span>
|
<span className="text-green-400 font-bold ml-auto">정상</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -490,7 +578,12 @@ export function ChinaFishing() {
|
|||||||
|
|
||||||
{/* 선박 목록 */}
|
{/* 선박 목록 */}
|
||||||
<div className="max-h-[420px] overflow-y-auto">
|
<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
|
<div
|
||||||
key={v.id}
|
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"
|
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} />
|
<StatusRing status={v.status} riskPct={v.riskPct} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 text-[10px] text-hint mb-0.5">
|
<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>MMSI | <span className="text-label">{v.mmsi}</span></span>
|
||||||
<span>호출부호 | <span className="text-label">{v.callSign}</span></span>
|
|
||||||
<span>출처 | <span className="text-label">{v.source}</span></span>
|
<span>출처 | <span className="text-label">{v.source}</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-[8px] px-1.5 py-0">{v.type}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
|
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
|
||||||
<span>🇰🇷</span>
|
|
||||||
<span>{v.country}</span>
|
<span>{v.country}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -543,75 +634,45 @@ export function ChinaFishing() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 flex gap-4">
|
<div className="p-4 flex gap-4">
|
||||||
{/* 바 차트 */}
|
{/* 월별 통계 - API 미지원, 준비중 안내 */}
|
||||||
<div className="flex-1">
|
<div className="flex-1 flex flex-col items-center justify-center py-8">
|
||||||
<BaseChart height={220} option={{
|
<div className="text-muted-foreground text-xs mb-2">월별 불법조업 통계</div>
|
||||||
grid: { top: 10, right: 10, bottom: 24, left: 36, containLabel: false },
|
<div className="text-hint text-[10px] bg-surface-overlay rounded-lg px-4 py-3 border border-border">
|
||||||
tooltip: { trigger: 'axis' },
|
월별 집계 API 연동 준비중입니다. 실시간 위험도 분포는 우측 도넛을 참고하세요.
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</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]">
|
<div className="relative w-[80px] h-[80px]">
|
||||||
<EcPieChart
|
<EcPieChart
|
||||||
data={[
|
data={[
|
||||||
{ name: 'active', value: 70, color: '#22c55e' },
|
{ name: 'CRITICAL', value: riskDistribution.critical || 1, color: '#ef4444' },
|
||||||
{ name: 'rest', value: 30, color: '#1e293b' },
|
{ 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}
|
height={80}
|
||||||
innerRadius={24}
|
innerRadius={24}
|
||||||
outerRadius={34}
|
outerRadius={34}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
<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-sm font-extrabold text-heading">{riskDistribution.total}</span>
|
||||||
<span className="text-[7px] text-hint">TOTAL</span>
|
<span className="text-[7px] text-hint">중국어선</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-[80px] h-[80px]">
|
<div className="space-y-0.5 text-[8px]">
|
||||||
<EcPieChart
|
<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>
|
||||||
data={[
|
<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>
|
||||||
{ name: 'active', value: 60, color: '#22c55e' },
|
<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>
|
||||||
{ name: 'rest', value: 40, color: '#1e293b' },
|
<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>
|
||||||
]}
|
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 다운로드 버튼 */}
|
{/* 다운로드 버튼 */}
|
||||||
<div className="px-4 pb-3 flex justify-end">
|
<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>
|
</button>
|
||||||
</div>
|
</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 { useTranslation } from 'react-i18next';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
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 { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||||
import type { MarkerData } from '@lib/map';
|
import type { MarkerData } from '@lib/map';
|
||||||
import { useVesselStore } from '@stores/vesselStore';
|
import {
|
||||||
import { RealDarkVessels, RealSpoofingVessels } from './RealVesselAnalysis';
|
fetchVesselAnalysis,
|
||||||
|
filterDarkVessels,
|
||||||
|
type VesselAnalysisItem,
|
||||||
|
} from '@/services/vesselAnalysisApi';
|
||||||
|
|
||||||
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */
|
/* 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; }
|
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> = {
|
const PATTERN_COLORS: Record<string, string> = {
|
||||||
'AIS 완전차단': '#ef4444',
|
'AIS 완전차단': '#ef4444',
|
||||||
'MMSI 3회 변경': '#f97316',
|
'MMSI 변조 의심': '#f97316',
|
||||||
'급격 속력변화': '#eab308',
|
'장기소실': '#eab308',
|
||||||
'신호 간헐송출': '#a855f7',
|
'신호 간헐송출': '#a855f7',
|
||||||
'비정기 신호': '#3b82f6',
|
|
||||||
'국적 위장 의심': '#ec4899',
|
|
||||||
};
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
|
||||||
'추적중': '#ef4444',
|
|
||||||
'감시중': '#eab308',
|
|
||||||
'확인중': '#3b82f6',
|
|
||||||
'정상': '#22c55e',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const cols: DataColumn<Suspect>[] = [
|
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: '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: '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: '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: 'flag', label: '국적', width: '50px' },
|
||||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||||
@ -46,33 +85,42 @@ const cols: DataColumn<Suspect>[] = [
|
|||||||
|
|
||||||
export function DarkVesselDetection() {
|
export function DarkVesselDetection() {
|
||||||
const { t } = useTranslation('detection');
|
const { t } = useTranslation('detection');
|
||||||
const { suspects, loaded, load } = useVesselStore();
|
const [darkItems, setDarkItems] = useState<VesselAnalysisItem[]>([]);
|
||||||
useEffect(() => { if (!loaded) load(); }, [loaded, load]);
|
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(
|
const DATA: Suspect[] = useMemo(
|
||||||
() =>
|
() => darkItems.map((item, i) => mapItemToSuspect(item, i)),
|
||||||
suspects.map((v) => ({
|
[darkItems],
|
||||||
id: v.id,
|
);
|
||||||
mmsi: v.mmsi,
|
|
||||||
name: v.name,
|
const avgRisk = useMemo(
|
||||||
flag: FLAG_MAP[v.flag] ?? v.flag,
|
() => DATA.length > 0 ? Math.round(DATA.reduce((s, d) => s + d.risk, 0) / DATA.length) : 0,
|
||||||
pattern: v.pattern ?? '-',
|
[DATA],
|
||||||
risk: v.risk,
|
|
||||||
lastAIS: v.lastSignal ?? '-',
|
|
||||||
status: v.status,
|
|
||||||
label: v.risk >= 90 ? (v.status === '추적중' ? '불법' : '-') : v.status === '정상' ? '정상' : '-',
|
|
||||||
lat: v.lat,
|
|
||||||
lng: v.lng,
|
|
||||||
})),
|
|
||||||
[suspects],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const mapRef = useRef<MapHandle>(null);
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
|
||||||
const buildLayers = useCallback(() => [
|
const buildLayers = useCallback(() => [
|
||||||
...STATIC_LAYERS,
|
...STATIC_LAYERS,
|
||||||
// 경보 반경 (고위험만)
|
|
||||||
createRadiusLayer(
|
createRadiusLayer(
|
||||||
'dv-radius',
|
'dv-radius',
|
||||||
DATA.filter(d => d.risk > 80).map(d => ({
|
DATA.filter(d => d.risk > 80).map(d => ({
|
||||||
@ -83,7 +131,6 @@ export function DarkVesselDetection() {
|
|||||||
})),
|
})),
|
||||||
0.08,
|
0.08,
|
||||||
),
|
),
|
||||||
// 탐지 선박 마커
|
|
||||||
createMarkerLayer(
|
createMarkerLayer(
|
||||||
'dv-markers',
|
'dv-markers',
|
||||||
DATA.map(d => ({
|
DATA.map(d => ({
|
||||||
@ -106,22 +153,36 @@ export function DarkVesselDetection() {
|
|||||||
<p className="text-[10px] text-hint mt-0.5">{t('darkVessel.desc')}</p>
|
<p className="text-[10px] text-hint mt-0.5">{t('darkVessel.desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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: 'Dark Vessel', v: DATA.length, c: 'text-red-400', i: AlertTriangle },
|
||||||
{ l: 'MMSI 변조', v: DATA.filter(d => d.pattern.includes('MMSI')).length, c: 'text-yellow-400', i: Radio },
|
{ l: 'AIS 완전차단', v: DATA.filter(d => d.pattern === 'AIS 완전차단').length, c: 'text-orange-400', i: EyeOff },
|
||||||
{ l: '라벨링 완료', v: DATA.filter(d => d.label !== '-').length + '/' + DATA.length, c: 'text-cyan-400', i: Tag },
|
{ 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 => (
|
].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">
|
<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>
|
<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>
|
||||||
))}
|
))}
|
||||||
</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>
|
<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 { useTranslation } from 'react-i18next';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
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 { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||||
import type { MarkerData } from '@lib/map';
|
import type { MarkerData } from '@lib/map';
|
||||||
import { useGearStore } from '@stores/gearStore';
|
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
||||||
import { RealGearGroups } from './RealGearGroups';
|
|
||||||
|
|
||||||
/* SFR-10: 불법 어망·어구 탐지 및 관리 */
|
/* SFR-10: 불법 어망·어구 탐지 및 관리 */
|
||||||
|
|
||||||
@ -19,14 +18,36 @@ const RISK_COLORS: Record<string, string> = {
|
|||||||
'안전': '#22c55e',
|
'안전': '#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>[] = [
|
const cols: DataColumn<Gear>[] = [
|
||||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
{ 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() {
|
export function GearDetection() {
|
||||||
const { t } = useTranslation('detection');
|
const { t } = useTranslation('detection');
|
||||||
const { items, loaded, load } = useGearStore();
|
const [groups, setGroups] = useState<GearGroupItem[]>([]);
|
||||||
useEffect(() => { if (!loaded) load(); }, [loaded, load]);
|
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 loadData = useCallback(async () => {
|
||||||
const DATA: Gear[] = items as unknown as Gear[];
|
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 mapRef = useRef<MapHandle>(null);
|
||||||
|
|
||||||
const buildLayers = useCallback(() => [
|
const buildLayers = useCallback(() => [
|
||||||
...STATIC_LAYERS,
|
...STATIC_LAYERS,
|
||||||
// 어구 설치 영역 (고위험만)
|
|
||||||
createRadiusLayer(
|
createRadiusLayer(
|
||||||
'gear-radius',
|
'gear-radius',
|
||||||
DATA.filter(g => g.risk === '고위험').map(g => ({
|
DATA.filter(g => g.risk === '고위험').map(g => ({
|
||||||
@ -65,7 +108,6 @@ export function GearDetection() {
|
|||||||
})),
|
})),
|
||||||
0.1,
|
0.1,
|
||||||
),
|
),
|
||||||
// 어구 마커
|
|
||||||
createMarkerLayer(
|
createMarkerLayer(
|
||||||
'gear-markers',
|
'gear-markers',
|
||||||
DATA.map(g => ({
|
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>
|
<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>
|
<p className="text-[10px] text-hint mt-0.5">{t('gearDetection.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 분석 서비스 미연결 - 실시간 어구 데이터를 불러올 수 없습니다</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">
|
<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">
|
<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>
|
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* iran 백엔드 실시간 어구/선단 그룹 */}
|
|
||||||
<RealGearGroups />
|
|
||||||
|
|
||||||
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="어구유형, 소유선박, 해역 검색..." searchKeys={['type', 'owner', 'zone']} exportFilename="어구탐지" />
|
<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 { useTranslation } from 'react-i18next';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
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 { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||||
import type { MarkerData } 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: 단속 계획·경보 연계(단속 우선지역 예보) */
|
/* 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; }
|
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>[] = [
|
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: '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> },
|
{ 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: '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: '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,
|
{ 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',
|
{ 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() {
|
export function EnforcementPlan() {
|
||||||
const { t } = useTranslation('enforcement');
|
const { t } = useTranslation('enforcement');
|
||||||
const { plans: storePlans, load } = useEnforcementStore();
|
|
||||||
useEffect(() => { load(); }, [load]);
|
|
||||||
|
|
||||||
const PLANS: Plan[] = useMemo(
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
() => storePlans.map((p) => ({ ...p } as Plan)),
|
const [loading, setLoading] = useState(false);
|
||||||
[storePlans],
|
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);
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
|
||||||
@ -42,7 +76,7 @@ export function EnforcementPlan() {
|
|||||||
...STATIC_LAYERS,
|
...STATIC_LAYERS,
|
||||||
createRadiusLayer(
|
createRadiusLayer(
|
||||||
'ep-radius-confirmed',
|
'ep-radius-confirmed',
|
||||||
PLANS.filter(p => p.status === '확정').map(p => ({
|
PLANS.filter(p => p.status === '확정' || p.status === 'CONFIRMED').map(p => ({
|
||||||
lat: p.lat,
|
lat: p.lat,
|
||||||
lng: p.lng,
|
lng: p.lng,
|
||||||
radius: 20000,
|
radius: 20000,
|
||||||
@ -52,7 +86,7 @@ export function EnforcementPlan() {
|
|||||||
),
|
),
|
||||||
createRadiusLayer(
|
createRadiusLayer(
|
||||||
'ep-radius-planned',
|
'ep-radius-planned',
|
||||||
PLANS.filter(p => p.status !== '확정').map(p => ({
|
PLANS.filter(p => p.status !== '확정' && p.status !== 'CONFIRMED').map(p => ({
|
||||||
lat: p.lat,
|
lat: p.lat,
|
||||||
lng: p.lng,
|
lng: p.lng,
|
||||||
radius: 20000,
|
radius: 20000,
|
||||||
@ -74,6 +108,15 @@ export function EnforcementPlan() {
|
|||||||
|
|
||||||
useMapLayers(mapRef, buildLayers, [PLANS]);
|
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 (
|
return (
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -83,8 +126,22 @@ export function EnforcementPlan() {
|
|||||||
</div>
|
</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>
|
<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>
|
</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">
|
<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">
|
<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>
|
<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>
|
||||||
|
|||||||
@ -4,7 +4,8 @@ export { getEvents, getEventById, ackEvent, updateEventStatus, getEventStats } f
|
|||||||
export type { PredictionEvent, EventPageResponse, EventStats } from './event';
|
export type { PredictionEvent, EventPageResponse, EventStats } from './event';
|
||||||
export { getEnforcementRecords, createEnforcementRecord, getEnforcementPlans } from './enforcement';
|
export { getEnforcementRecords, createEnforcementRecord, getEnforcementPlans } from './enforcement';
|
||||||
export type { EnforcementRecord, EnforcementPlan } from './enforcement';
|
export type { EnforcementRecord, EnforcementPlan } from './enforcement';
|
||||||
export { getPatrolShips } from './patrol';
|
export { getPatrolShips, updatePatrolShipStatus, toLegacyPatrolShip } from './patrol';
|
||||||
|
export type { PatrolShipApi } from './patrol';
|
||||||
export {
|
export {
|
||||||
getKpiMetrics,
|
getKpiMetrics,
|
||||||
getMonthlyStats,
|
getMonthlyStats,
|
||||||
|
|||||||
@ -1,10 +1,71 @@
|
|||||||
/**
|
/**
|
||||||
* <EFBFBD><EFBFBD><EFBFBD>비함정/순찰 API 서비스
|
* 경비함정/순찰 API 서비스 -- 실제 백엔드 연동
|
||||||
*/
|
*/
|
||||||
import type { PatrolShip } from '@data/mock/patrols';
|
import type { PatrolShip } from '@data/mock/patrols';
|
||||||
import { MOCK_PATROL_SHIPS } from '@data/mock/patrols';
|
|
||||||
|
|
||||||
/** TODO: GET /api/v1/patrols/ships */
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||||
export async function getPatrolShips(): Promise<PatrolShip[]> {
|
|
||||||
return MOCK_PATROL_SHIPS;
|
// ─── 서버 응답 타입 ───────────────────────────────
|
||||||
|
|
||||||
|
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,
|
PatrolScenario,
|
||||||
CoverageZone,
|
CoverageZone,
|
||||||
} from '@data/mock/patrols';
|
} from '@data/mock/patrols';
|
||||||
|
import { getPatrolShips, toLegacyPatrolShip } from '@/services/patrol';
|
||||||
|
|
||||||
interface PatrolStore {
|
interface PatrolStore {
|
||||||
ships: PatrolShip[];
|
ships: PatrolShip[];
|
||||||
@ -14,7 +15,9 @@ interface PatrolStore {
|
|||||||
fleetRoutes: Record<string, [number, number][]>;
|
fleetRoutes: Record<string, [number, number][]>;
|
||||||
selectedShipId: string | null;
|
selectedShipId: string | null;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
load: () => void;
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
load: () => Promise<void>;
|
||||||
selectShip: (id: string | null) => void;
|
selectShip: (id: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,27 +29,39 @@ export const usePatrolStore = create<PatrolStore>((set, get) => ({
|
|||||||
fleetRoutes: {},
|
fleetRoutes: {},
|
||||||
selectedShipId: null,
|
selectedShipId: null,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
load: () => {
|
load: async () => {
|
||||||
if (get().loaded) return;
|
if (get().loaded && !get().error) return;
|
||||||
import('@data/mock/patrols').then(
|
|
||||||
({
|
set({ loading: true, error: null });
|
||||||
MOCK_PATROL_SHIPS,
|
try {
|
||||||
MOCK_PATROL_ROUTES,
|
// 함정 목록은 API에서, 나머지(routes/scenarios/coverage)는 mock 유지
|
||||||
MOCK_PATROL_SCENARIOS,
|
const [apiShips, mockModule] = await Promise.all([
|
||||||
MOCK_COVERAGE_ZONES,
|
getPatrolShips(),
|
||||||
MOCK_FLEET_ROUTES,
|
get().routes && Object.keys(get().routes).length > 0
|
||||||
}) => {
|
? Promise.resolve(null)
|
||||||
set({
|
: import('@data/mock/patrols').then((m) => ({
|
||||||
ships: MOCK_PATROL_SHIPS,
|
routes: m.MOCK_PATROL_ROUTES,
|
||||||
routes: MOCK_PATROL_ROUTES,
|
scenarios: m.MOCK_PATROL_SCENARIOS,
|
||||||
scenarios: MOCK_PATROL_SCENARIOS,
|
coverage: m.MOCK_COVERAGE_ZONES,
|
||||||
coverage: MOCK_COVERAGE_ZONES,
|
fleetRoutes: m.MOCK_FLEET_ROUTES,
|
||||||
fleetRoutes: MOCK_FLEET_ROUTES,
|
})),
|
||||||
loaded: true,
|
]);
|
||||||
});
|
|
||||||
},
|
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 }),
|
selectShip: (id) => set({ selectedShipId: id }),
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user