## 시간 표시 KST 통일 - shared/utils/dateFormat.ts 공통 유틸 신규 (formatDateTime/formatDate/formatTime/toDateParam) - 14개 파일에서 인라인 toLocaleString → 공통 유틸 교체 ## i18n 'group.parentInference' 사이드바 미번역 수정 - ko/en common.json의 'group' 키 중복 정의를 병합 (95행 두번째 group 객체가 35행을 덮어써서 parentInference 누락) ## Dashboard/MonitoringDashboard/Statistics 더미→실 API - 백엔드 GET /api/stats/hourly 신규 (PredictionStatsHourly 엔티티/리포지토리) - Dashboard: HOURLY_DETECTION/VESSEL_TYPE/AREA_RISK 하드코딩 제거 → getHourlyStats(24) + getDailyStats(today) 결과로 useMemo 변환 - MonitoringDashboard: TREND Math.random() 제거 → getHourlyStats 기반 위험도 가중평균 + 경보 카운트 - Statistics: KPI_DATA 하드코딩 제거 → getKpiMetrics() 결과를 표 행으로 ## Store mock 의존성 제거 - eventStore.alerts/MOCK_ALERTS 제거 (MobileService는 events에서 직접 추출) - enforcementStore.plans 제거 (EnforcementPlan은 이미 직접 API 호출) - transferStore + MOCK_TRANSFERS 완전 제거 (ChinaFishing/TransferDetection은 RealTransshipSuspects 컴포넌트 사용) - mock/events.ts, mock/enforcement.ts, mock/transfers.ts 파일 삭제 ## RiskMap 랜덤 격자 제거 - generateGrid() Math.random() 제거 → 빈 배열 + 'AI 분석 데이터 수집 중' 안내 - MTIS 외부 통계 5개 탭에 [MTIS 외부 통계] 배지 추가 ## 12개 mock 화면에 '데모 데이터' 노란색 배지 추가 - patrol/PatrolRoute, FleetOptimization - admin/AdminPanel, DataHub, NoticeManagement, SystemConfig - ai-operations/AIModelManagement, MLOpsPage - field-ops/ShipAgent - statistics/ReportManagement, ExternalService - surveillance/MapControl ## 백엔드 NUMERIC precision 동기화 - PredictionKpi.deltaPct: 5,2 → 12,2 - PredictionStatsDaily/Monthly.aiAccuracyPct: 5,2 → 12,2 - (V015 마이그레이션과 동기화) 44 files changed, +346 / -787 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
677 lines
31 KiB
TypeScript
677 lines
31 KiB
TypeScript
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, Radio, RotateCcw,
|
|
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
|
|
} from 'lucide-react';
|
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
|
import { GearIdentification } from './GearIdentification';
|
|
import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis';
|
|
import { PieChart as EcPieChart } from '@lib/charts';
|
|
import {
|
|
fetchVesselAnalysis,
|
|
filterDarkVessels,
|
|
filterTransshipSuspects,
|
|
type VesselAnalysisItem,
|
|
type VesselAnalysisStats,
|
|
} from '@/services/vesselAnalysisApi';
|
|
|
|
// ─── 중국 MMSI prefix ─────────────
|
|
const CHINA_MMSI_PREFIX = '412';
|
|
|
|
function isChinaVessel(mmsi: string): boolean {
|
|
return mmsi.startsWith(CHINA_MMSI_PREFIX);
|
|
}
|
|
|
|
// ─── 특이운항 선박 리스트 타입 ────────────────
|
|
type VesselStatus = '의심' | '양호' | '경고';
|
|
interface VesselItem {
|
|
id: string;
|
|
mmsi: string;
|
|
callSign: string;
|
|
channel: string;
|
|
source: string;
|
|
name: string;
|
|
type: string;
|
|
country: string;
|
|
status: VesselStatus;
|
|
riskPct: number;
|
|
}
|
|
|
|
function deriveVesselStatus(score: number): VesselStatus {
|
|
if (score >= 70) return '경고';
|
|
if (score >= 40) return '의심';
|
|
return '양호';
|
|
}
|
|
|
|
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 = [
|
|
{ name: '경인연안', active: true },
|
|
{ name: '평택항', active: false },
|
|
{ name: '경인항', active: false },
|
|
{ name: '대산항', active: true },
|
|
{ name: '인천항', active: true },
|
|
{ name: '태안연안', active: false },
|
|
];
|
|
|
|
// ─── 환적 탐지 뷰: RealTransshipSuspects 컴포넌트 사용 ───
|
|
|
|
// ─── 서브 컴포넌트 ─────────────────────
|
|
|
|
function SemiGauge({ value, label, color }: { value: number; label: string; color: string }) {
|
|
const angle = (value / 100) * 180;
|
|
return (
|
|
<div className="flex flex-col items-center">
|
|
<div className="relative w-28 h-16 overflow-hidden">
|
|
<svg viewBox="0 0 120 65" className="w-full h-full">
|
|
{/* 배경 호 */}
|
|
<path d="M 10 60 A 50 50 0 0 1 110 60" fill="none" stroke="#1e293b" strokeWidth="10" strokeLinecap="round" />
|
|
{/* 값 호 */}
|
|
<path
|
|
d="M 10 60 A 50 50 0 0 1 110 60"
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth="10"
|
|
strokeLinecap="round"
|
|
strokeDasharray={`${(angle / 180) * 157} 157`}
|
|
/>
|
|
</svg>
|
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 text-center">
|
|
<span className="text-xl font-extrabold text-heading">{value.toFixed(2)}</span>
|
|
<span className="text-xs text-muted-foreground ml-0.5">%</span>
|
|
</div>
|
|
</div>
|
|
<span className="text-[10px] text-muted-foreground mt-1">{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CircleGauge({ value, label }: { value: number; label: string }) {
|
|
const circumference = 2 * Math.PI * 42;
|
|
const offset = circumference - (value / 100) * circumference;
|
|
return (
|
|
<div className="flex flex-col items-center">
|
|
<div className="relative w-24 h-24">
|
|
<svg viewBox="0 0 100 100" className="w-full h-full -rotate-90">
|
|
<circle cx="50" cy="50" r="42" fill="none" stroke="#1e293b" strokeWidth="8" />
|
|
<circle
|
|
cx="50" cy="50" r="42" fill="none"
|
|
stroke="#10b981" strokeWidth="8" strokeLinecap="round"
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={offset}
|
|
/>
|
|
</svg>
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
<span className="text-2xl font-extrabold text-heading">{value}</span>
|
|
<span className="text-[10px] text-muted-foreground">%</span>
|
|
</div>
|
|
</div>
|
|
<span className="text-[10px] text-muted-foreground mt-1">{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusRing({ status, riskPct }: { status: VesselStatus; riskPct: number }) {
|
|
const colors: Record<VesselStatus, { ring: string; bg: string; text: string }> = {
|
|
'의심': { ring: '#f97316', bg: 'bg-orange-500/10', text: 'text-orange-400' },
|
|
'양호': { ring: '#10b981', bg: 'bg-green-500/10', text: 'text-green-400' },
|
|
'경고': { ring: '#ef4444', bg: 'bg-red-500/10', text: 'text-red-400' },
|
|
};
|
|
const c = colors[status];
|
|
const circumference = 2 * Math.PI * 18;
|
|
const offset = circumference - (riskPct / 100) * circumference;
|
|
|
|
return (
|
|
<div className="relative w-12 h-12 shrink-0">
|
|
<svg viewBox="0 0 44 44" className="w-full h-full -rotate-90">
|
|
<circle cx="22" cy="22" r="18" fill="none" stroke="#1e293b" strokeWidth="3" />
|
|
<circle cx="22" cy="22" r="18" fill="none" stroke={c.ring} strokeWidth="3" strokeLinecap="round"
|
|
strokeDasharray={circumference} strokeDashoffset={offset} />
|
|
</svg>
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
<span className={`text-[8px] font-bold ${c.text}`}>{status}</span>
|
|
<span className="text-[9px] font-bold text-heading">{riskPct}%</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 메인 페이지 ──────────────────────
|
|
|
|
// ─── 환적 탐지 뷰 ─────────────────────
|
|
|
|
function TransferView() {
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h2 className="text-base font-bold text-heading">환적·접촉 탐지</h2>
|
|
<p className="text-[10px] text-hint mt-0.5">선박 간 근접 접촉 및 환적 의심 행위 분석</p>
|
|
</div>
|
|
|
|
{/* 탐지 조건 */}
|
|
<Card className="bg-surface-raised border-slate-700/30">
|
|
<CardContent className="p-4">
|
|
<div className="text-[10px] text-muted-foreground mb-2">탐지 조건</div>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="bg-surface-overlay rounded-lg p-3 text-center border border-slate-700/30">
|
|
<div className="text-[9px] text-hint mb-1">거리</div>
|
|
<div className="text-lg font-bold text-heading">≤ 100m</div>
|
|
</div>
|
|
<div className="bg-surface-overlay rounded-lg p-3 text-center border border-slate-700/30">
|
|
<div className="text-[9px] text-hint mb-1">시간</div>
|
|
<div className="text-lg font-bold text-heading">≥ 30분</div>
|
|
</div>
|
|
<div className="bg-surface-overlay rounded-lg p-3 text-center border border-slate-700/30">
|
|
<div className="text-[9px] text-hint mb-1">속도</div>
|
|
<div className="text-lg font-bold text-heading">≤ 3kn</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* prediction 분석 결과 기반 환적 의심 선박 */}
|
|
<RealTransshipSuspects />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 메인 페이지 ──────────────────────
|
|
|
|
export function ChinaFishing() {
|
|
const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard');
|
|
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;
|
|
|
|
const modeTabs = [
|
|
{ key: 'dashboard' as const, icon: Brain, label: 'AI 감시 대시보드' },
|
|
{ key: 'transfer' as const, icon: RefreshCw, label: '환적·접촉 탐지' },
|
|
{ key: 'gear' as const, icon: CrosshairIcon, label: '어구/어망 판별' },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* ── 모드 탭 (AI 대시보드 / 환적 탐지) ── */}
|
|
<div className="flex items-center gap-1 bg-surface-raised rounded-lg p-1 border border-slate-700/30 w-fit">
|
|
{modeTabs.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setMode(tab.key)}
|
|
className={`flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors ${
|
|
mode === tab.key
|
|
? 'bg-blue-600 text-heading'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay'
|
|
}`}
|
|
>
|
|
<tab.icon className="w-3.5 h-3.5" />
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 환적 탐지 모드 */}
|
|
{mode === 'transfer' && <TransferView />}
|
|
|
|
{/* 어구/어망 판별 모드 */}
|
|
{mode === 'gear' && <GearIdentification />}
|
|
|
|
{/* 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 />
|
|
|
|
{/* ── 상단 바: 기준일 + 검색 ── */}
|
|
<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">기준 : {formatDateTime(new Date())}</span>
|
|
</div>
|
|
<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">
|
|
<Search className="w-3.5 h-3.5 text-hint mr-2" />
|
|
<input
|
|
placeholder="해역 또는 해구 번호 검색"
|
|
className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none"
|
|
/>
|
|
<Search className="w-4 h-4 text-blue-500 cursor-pointer" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 상단 영역: 통항량 + 안전도 분석 + 관심영역 ── */}
|
|
<div className="grid grid-cols-12 gap-3">
|
|
|
|
{/* 해역별 통항량 */}
|
|
<div className="col-span-4">
|
|
<Card className="bg-surface-raised border-slate-700/30 h-full">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-sm font-bold text-heading">해역별 통항량</span>
|
|
{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">{allItems.length.toLocaleString()}</span>
|
|
<span className="text-hint">(척)</span>
|
|
</div>
|
|
|
|
{/* 카운터 Row 1 */}
|
|
<div className="grid grid-cols-4 gap-2 mb-2">
|
|
{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>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* 카운터 Row 2 */}
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{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 ${c.count > 0 ? 'text-heading' : 'text-muted'}`}>
|
|
{c.count > 0 ? c.count.toLocaleString() : '-'}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 안전도 분석 */}
|
|
<div className="col-span-4">
|
|
<Card className="bg-surface-raised border-slate-700/30 h-full">
|
|
<CardContent className="p-4">
|
|
<span className="text-sm font-bold text-heading">안전도 분석</span>
|
|
<div className="flex items-center justify-around mt-4">
|
|
<div>
|
|
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
|
<span className="text-orange-400 font-medium">종합</span> 위험지수
|
|
</div>
|
|
<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={safetyIndex.safety} label="" color="#3b82f6" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 관심영역 안전도 */}
|
|
<div className="col-span-4">
|
|
<Card className="bg-surface-raised border-slate-700/30 h-full">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-bold text-heading">관심영역 안전도</span>
|
|
<select className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
|
|
<option>영역 A</option>
|
|
<option>영역 B</option>
|
|
</select>
|
|
</div>
|
|
<p className="text-[9px] text-hint mb-3">설정한 관심 영역을 선택후 조회를 눌러주세요.</p>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<div className="space-y-2 flex-1">
|
|
<div className="flex items-center gap-2 text-[11px]">
|
|
<Eye className="w-3.5 h-3.5 text-blue-400" />
|
|
<span className="text-muted-foreground">특이운항</span>
|
|
<span className="text-green-400 font-bold ml-auto">정상</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-[11px]">
|
|
<AlertTriangle className="w-3.5 h-3.5 text-red-400" />
|
|
<span className="text-muted-foreground">불법조업</span>
|
|
<span className="text-green-400 font-bold ml-auto">정상</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-[11px]">
|
|
<Radio className="w-3.5 h-3.5 text-purple-400" />
|
|
<span className="text-muted-foreground">비허가</span>
|
|
<span className="text-green-400 font-bold ml-auto">정상</span>
|
|
</div>
|
|
</div>
|
|
<CircleGauge value={chinaVessels.length > 0 ? Number(((1 - riskDistribution.critical / Math.max(chinaVessels.length, 1)) * 100).toFixed(1)) : 100} label="" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 하단 영역: 선박 리스트 + 통계 ── */}
|
|
<div className="grid grid-cols-12 gap-3">
|
|
|
|
{/* 좌: 선박 리스트 (탭) */}
|
|
<div className="col-span-5">
|
|
<Card className="bg-surface-raised border-slate-700/30">
|
|
<CardContent className="p-0">
|
|
{/* 탭 헤더 */}
|
|
<div className="flex border-b border-slate-700/30">
|
|
{vesselTabs.map((tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setVesselTab(tab)}
|
|
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
|
|
vesselTab === tab
|
|
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
|
|
: 'text-hint hover:text-label'
|
|
}`}
|
|
>
|
|
{tab}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 선박 목록 */}
|
|
<div className="max-h-[420px] overflow-y-auto">
|
|
{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"
|
|
>
|
|
<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>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">
|
|
<span className="text-[12px] font-bold text-heading">{v.name}</span>
|
|
<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>{v.country}</span>
|
|
</div>
|
|
</div>
|
|
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 우: 통계 + 하단 카드 3개 */}
|
|
<div className="col-span-7 space-y-3">
|
|
|
|
{/* 통계 차트 */}
|
|
<Card className="bg-surface-raised border-slate-700/30">
|
|
<CardContent className="p-0">
|
|
{/* 탭 */}
|
|
<div className="flex border-b border-slate-700/30">
|
|
{statsTabs.map((tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setStatsTab(tab)}
|
|
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
|
|
statsTab === tab
|
|
? 'text-heading border-b-2 border-green-500 bg-surface-overlay'
|
|
: 'text-hint hover:text-label'
|
|
}`}
|
|
>
|
|
{tab}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="p-4 flex gap-4">
|
|
{/* 월별 통계 - 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>
|
|
|
|
{/* 위험도 분포 도넛 */}
|
|
<div className="flex flex-col items-center justify-center gap-3 w-28">
|
|
<div className="relative w-[80px] h-[80px]">
|
|
<EcPieChart
|
|
data={[
|
|
{ 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">{riskDistribution.total}</span>
|
|
<span className="text-[7px] text-hint">중국어선</span>
|
|
</div>
|
|
</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 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>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 하단 카드 3개 */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
|
|
{/* 최근 위성영상 분석 */}
|
|
<Card className="bg-surface-raised border-slate-700/30">
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-[11px] font-bold text-heading">최근 위성영상 분석</span>
|
|
<button className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
|
</div>
|
|
<div className="space-y-1.5 text-[10px]">
|
|
<div className="flex gap-2">
|
|
<span className="text-hint shrink-0">VIIRS</span>
|
|
<span className="text-label">| 2023-08-11 02:00:00</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<span className="text-hint shrink-0">이미지</span>
|
|
<span className="text-label truncate">| BSG-117-20230194-orho.tif</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<span className="text-hint shrink-0">CSV</span>
|
|
<span className="text-label truncate">| 2023.07.17_ship_dection.clustog.csv</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 기상 예보 */}
|
|
<Card className="bg-surface-raised border-slate-700/30">
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-[11px] font-bold text-heading">기상 예보</span>
|
|
<button className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="text-center">
|
|
<Cloud className="w-8 h-8 text-yellow-400 mx-auto" />
|
|
</div>
|
|
<div>
|
|
<div className="text-[9px] text-muted-foreground">전남서부남해앞바다</div>
|
|
<div className="flex items-baseline gap-1">
|
|
<span className="text-2xl font-extrabold text-heading">28.1</span>
|
|
<span className="text-sm text-muted-foreground">°C</span>
|
|
</div>
|
|
<div className="text-[9px] text-hint">흐림 남~남서 🌊 0.5~0.5m</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* VTS연계 현황 */}
|
|
<Card className="bg-surface-raised border-slate-700/30">
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-[11px] font-bold text-heading">VTS연계 현황</span>
|
|
<button className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
{VTS_ITEMS.map((vts) => (
|
|
<div
|
|
key={vts.name}
|
|
className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] ${
|
|
vts.active
|
|
? 'bg-orange-500/15 text-orange-400 border border-orange-500/20'
|
|
: 'bg-surface-overlay text-muted-foreground border border-slate-700/30'
|
|
}`}
|
|
>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${vts.active ? 'bg-orange-400' : 'bg-muted'}`} />
|
|
{vts.name}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex justify-between mt-2">
|
|
<button className="text-hint hover:text-heading transition-colors">
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
<button className="text-hint hover:text-heading transition-colors">
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</>}
|
|
</div>
|
|
);
|
|
}
|