- FAO ISSCFG 기준 5종 어구(저층트롤/쌍끌이/스토우넷/자망/통발) 특성 비교 표 - 어구별 구조 도식 5개 (이미지 + 사양 + G코드 연계) - AIS 신호 특성 및 이상 판정 기준 비교 표 - 근거: FAO 분류 + Wang et al.(2022) 논문 - 이미지 5장 /public/dar03/ 배포 - 디자인 시스템 준수 (Card/Badge intent/시맨틱 토큰) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1296 lines
73 KiB
TypeScript
1296 lines
73 KiB
TypeScript
import { useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||
import { Badge } from '@shared/components/ui/badge';
|
||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||
import {
|
||
Brain, Settings, Zap, Activity, TrendingUp, BarChart3,
|
||
Target, Eye, AlertTriangle, CheckCircle, RefreshCw,
|
||
Play, Square, Upload, GitBranch, Layers, Shield,
|
||
Anchor, Ship, Radio, Radar, Clock, ArrowUpRight, ArrowDownRight,
|
||
FileText, ChevronRight, Info, Cpu, Database, Globe, Code, Copy, ExternalLink,
|
||
} from 'lucide-react';
|
||
import { AreaChart as EcAreaChart, BarChart as EcBarChart, PieChart as EcPieChart } from '@lib/charts';
|
||
import { getEngineSeverityIntent, getEngineSeverityLabel } from '@shared/constants/engineSeverities';
|
||
import { getStatusIntent } from '@shared/constants/statusIntent';
|
||
import type { BadgeIntent } from '@lib/theme/variants';
|
||
import { useSettingsStore } from '@stores/settingsStore';
|
||
|
||
/*
|
||
* SFR-04: AI 불법조업 예측 모델 관리
|
||
*
|
||
* ① 모델 레지스트리 — 버전 관리, 배포 이력, 성능 비교
|
||
* ② 탐지 규칙 관리 — ON/OFF, 가중치 조정
|
||
* ③ 피처 엔지니어링 — Kinematic/Geometric/Temporal/Behavioral/Contextual
|
||
* ④ 학습 파이프라인 — 데이터→전처리→학습→평가→배포
|
||
* ⑤ 성능 모니터링 — 정확도, Recall, F1, 오탐률, 리드타임 추이
|
||
* ⑥ 어구 탐지 모델 — GB/T 5147 어구 분류, 피처 프로파일
|
||
* ⑦ 7대 탐지 엔진 — 불법조업 감시 알고리즘 v4.0 (906척 허가어선)
|
||
*/
|
||
|
||
// ─── 탭 정의 ─────────────────────────
|
||
|
||
type Tab = 'registry' | 'rules' | 'features' | 'pipeline' | 'monitoring' | 'gear' | 'engines' | 'api';
|
||
|
||
// ─── ① 모델 레지스트리 ──────────────────
|
||
|
||
interface ModelVersion {
|
||
version: string;
|
||
status: '운영중' | '대기' | '테스트' | '폐기';
|
||
accuracy: number;
|
||
recall: number;
|
||
f1: number;
|
||
falseAlarm: number;
|
||
trainData: string;
|
||
deployDate: string;
|
||
note: string;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
const MODELS: ModelVersion[] = [
|
||
{ version: 'v2.4.0', status: '테스트', accuracy: 93.2, recall: 91.5, f1: 92.3, falseAlarm: 7.8, trainData: '1,456,200', deployDate: '-', note: '다크베셀 탐지 강화' },
|
||
{ version: 'v2.3.1', status: '운영중', accuracy: 90.1, recall: 88.7, f1: 89.4, falseAlarm: 9.9, trainData: '1,245,891', deployDate: '2026-03-01', note: 'MMSI 변조 탐지 개선' },
|
||
{ version: 'v2.2.0', status: '대기', accuracy: 87.5, recall: 85.2, f1: 86.3, falseAlarm: 12.5, trainData: '1,102,340', deployDate: '2026-01-15', note: '환적 탐지 추가' },
|
||
{ version: 'v2.1.0', status: '폐기', accuracy: 84.3, recall: 82.1, f1: 83.2, falseAlarm: 15.7, trainData: '980,120', deployDate: '2025-11-01', note: '초기 멀티센서 융합' },
|
||
{ version: 'v2.0.0', status: '폐기', accuracy: 81.0, recall: 78.5, f1: 79.7, falseAlarm: 19.0, trainData: '750,000', deployDate: '2025-08-15', note: '베이스라인 모델' },
|
||
];
|
||
|
||
const modelColumns: DataColumn<ModelVersion>[] = [
|
||
{ key: 'version', label: '버전', width: '80px', sortable: true, render: (v) => <span className="text-cyan-400 font-bold">{v as string}</span> },
|
||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||
render: (v) => {
|
||
const s = v as string;
|
||
return <Badge intent={getStatusIntent(s)} size="xs">{s}</Badge>;
|
||
},
|
||
},
|
||
{ key: 'accuracy', label: 'Accuracy', width: '80px', align: 'right', sortable: true, render: (v) => <span className="text-heading font-bold">{v as number}%</span> },
|
||
{ key: 'recall', label: 'Recall', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
|
||
{ key: 'f1', label: 'F1', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
|
||
{ key: 'falseAlarm', label: '오탐률', width: '70px', align: 'right', sortable: true,
|
||
render: (v) => { const n = v as number; return <span className={n < 10 ? 'text-green-400' : n < 15 ? 'text-yellow-400' : 'text-red-400'}>{n}%</span>; },
|
||
},
|
||
{ key: 'trainData', label: '학습데이터', width: '100px', align: 'right' },
|
||
{ key: 'deployDate', label: '배포일', width: '100px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||
{ key: 'note', label: '비고', render: (v) => <span className="text-hint">{v as string}</span> },
|
||
];
|
||
|
||
// ─── ② 탐지 규칙 ──────────────────────
|
||
|
||
const defaultRules = [
|
||
{ name: 'EEZ 침범 탐지', model: 'LSTM+CNN', accuracy: 95, enabled: true, weight: 30, desc: '배타적경제수역 무단 진입 실시간 탐지' },
|
||
{ name: 'MMSI 변조 탐지', model: 'Transformer', accuracy: 92, enabled: true, weight: 25, desc: 'MMSI 번호 위조·변경 패턴 감지' },
|
||
{ name: '다크베셀 탐지', model: 'SAR+RF Fusion', accuracy: 91, enabled: true, weight: 20, desc: 'AIS 미송출 선박 위성·RF 기반 탐지' },
|
||
{ name: 'AIS 신호 소실 탐지', model: 'Anomaly Det.', accuracy: 89, enabled: true, weight: 10, desc: 'AIS 고의 비활성화 패턴 분석' },
|
||
{ name: '불법환적 탐지', model: 'GNN', accuracy: 87, enabled: true, weight: 10, desc: '어선-운반선 간 접현·환적 행위 탐지' },
|
||
{ name: '불법 조업 패턴', model: 'Ensemble', accuracy: 78, enabled: false, weight: 5, desc: '궤적 기반 불법 조업 행위 패턴 분류' },
|
||
];
|
||
|
||
// ─── ③ 피처 엔지니어링 ──────────────────
|
||
|
||
const FEATURE_CATEGORIES = [
|
||
{ cat: 'Kinematic', color: '#3b82f6', icon: Activity, features: [
|
||
{ name: 'Mean Speed', desc: '평균속도', unit: 'kt' },
|
||
{ name: 'Speed Std', desc: '속도편차', unit: 'kt' },
|
||
{ name: 'Low Speed Ratio', desc: '저속비율', unit: '%' },
|
||
{ name: 'High Speed Ratio', desc: '고속비율', unit: '%' },
|
||
]},
|
||
{ cat: 'Geometric', color: '#8b5cf6', icon: Target, features: [
|
||
{ name: 'Trajectory Curvature', desc: '궤적 곡률', unit: '' },
|
||
{ name: 'Turn Angle', desc: '회전각', unit: '°' },
|
||
{ name: 'Circularity Index', desc: '원형지수', unit: '' },
|
||
{ name: 'Path Entropy', desc: '경로 엔트로피', unit: '' },
|
||
]},
|
||
{ cat: 'Temporal', color: '#f59e0b', icon: Clock, features: [
|
||
{ name: 'Dwell Time', desc: '정지시간', unit: 'min' },
|
||
{ name: 'Operation Duration', desc: '조업지속시간', unit: 'hr' },
|
||
{ name: 'Revisit Freq', desc: '반복방문주기', unit: 'day' },
|
||
{ name: 'Night Activity', desc: '야간활동비율', unit: '%' },
|
||
]},
|
||
{ cat: 'Behavioral', color: '#ef4444', icon: Eye, features: [
|
||
{ name: 'Speed Transition', desc: '속도변화패턴', unit: '' },
|
||
{ name: 'Stop-Move Pattern', desc: '정지-이동 패턴', unit: '' },
|
||
{ name: 'Scanning Pattern', desc: '탐색패턴', unit: '' },
|
||
{ name: 'Drift Pattern', desc: '표류패턴', unit: '' },
|
||
]},
|
||
{ cat: 'Contextual', color: '#10b981', icon: Layers, features: [
|
||
{ name: 'Vessel Density', desc: '주변 선박밀도', unit: '척/nm²' },
|
||
{ name: 'Fleet Coherence', desc: '선단 동기화', unit: '' },
|
||
{ name: 'EEZ Distance', desc: 'EEZ 경계거리', unit: 'nm' },
|
||
{ name: 'Fishing Zone', desc: '조업구역 여부', unit: '' },
|
||
]},
|
||
];
|
||
|
||
// ─── ④ 파이프라인 ──────────────────────
|
||
|
||
const PIPELINE_STAGES = [
|
||
{ stage: '데이터 수집', status: '정상', items: ['AIS/VMS', 'SAR 위성', '광학 위성', 'V-PASS/E-NAVI', 'VTS/VHF', '기상·해양'], icon: Database, color: '#3b82f6' },
|
||
{ stage: '전처리', status: '정상', items: ['노이즈 제거', '결측값 보정', '시간 동기화', '공간 정합', '정규화'], icon: Settings, color: '#8b5cf6' },
|
||
{ stage: '피처 추출', status: '정상', items: ['항적 패턴', '조업행동', '경계선 거리', '환경 컨텍스트'], icon: Cpu, color: '#f59e0b' },
|
||
{ stage: 'AI 학습', status: '진행중', items: ['LSTM+CNN', 'Transformer', 'GNN', 'Ensemble'], icon: Brain, color: '#ef4444' },
|
||
{ stage: '평가', status: '대기', items: ['Confusion Matrix', '과적합 검사', 'K-Fold 검증', '드리프트 감시'], icon: BarChart3, color: '#10b981' },
|
||
{ stage: '배포', status: '대기', items: ['Online Serving', '버전 관리', 'MDA 연계', '대시보드 출력'], icon: Upload, color: '#06b6d4' },
|
||
];
|
||
|
||
// ─── ⑤ 성능 모니터링 차트 데이터 ──────────
|
||
|
||
const PERF_HISTORY = [
|
||
{ month: '10월', accuracy: 81.0, recall: 78.5, f1: 79.7, falseAlarm: 19.0 },
|
||
{ month: '11월', accuracy: 84.3, recall: 82.1, f1: 83.2, falseAlarm: 15.7 },
|
||
{ month: '12월', accuracy: 85.8, recall: 83.5, f1: 84.6, falseAlarm: 14.2 },
|
||
{ month: '1월', accuracy: 87.5, recall: 85.2, f1: 86.3, falseAlarm: 12.5 },
|
||
{ month: '2월', accuracy: 89.0, recall: 87.1, f1: 88.0, falseAlarm: 11.0 },
|
||
{ month: '3월', accuracy: 90.1, recall: 88.7, f1: 89.4, falseAlarm: 9.9 },
|
||
{ month: '4월', accuracy: 93.2, recall: 91.5, f1: 92.3, falseAlarm: 7.8 },
|
||
];
|
||
|
||
const DETECTION_BY_TYPE = [
|
||
{ name: 'EEZ 침범', value: 35, color: '#ef4444' },
|
||
{ name: '다크베셀', value: 25, color: '#f97316' },
|
||
{ name: 'MMSI 변조', value: 18, color: '#eab308' },
|
||
{ name: '불법환적', value: 12, color: '#8b5cf6' },
|
||
{ name: '조업패턴', value: 10, color: '#3b82f6' },
|
||
];
|
||
|
||
// ─── ⑥ 어구 탐지 모델 ──────────────────
|
||
|
||
interface GearCode {
|
||
code: string;
|
||
name: string;
|
||
risk: '고위험' | '중위험' | '저위험';
|
||
speed: string;
|
||
feature: string;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
const GEAR_CODES: GearCode[] = [
|
||
{ code: 'TDD', name: '단선 저층트롤', risk: '고위험', speed: '2.5-4.5kt', feature: '저속직선왕복' },
|
||
{ code: 'TDS', name: '쌍선 저층트롤', risk: '고위험', speed: '2.0-3.5kt', feature: '2선편대동기속도' },
|
||
{ code: 'WDD', name: '집어등 단선선망', risk: '고위험', speed: '0-1.5kt', feature: '야간정선원형항적' },
|
||
{ code: 'WSS', name: '집어등 쌍선선망', risk: '고위험', speed: '0-2.0kt', feature: '대향포위VHF교신' },
|
||
{ code: 'CLD', name: '단선 유자망', risk: '고위험', speed: '0.5-2.0kt', feature: '해류동조장시간저속' },
|
||
{ code: 'TZD', name: '단선 중층트롤', risk: '중위험', speed: '3.0-5.0kt', feature: '지그재그어군반응' },
|
||
{ code: 'TXD', name: '새우 트롤', risk: '중위험', speed: '1.5-3.0kt', feature: '연안접근극저속' },
|
||
{ code: 'DYD', name: '단선 연승낚시', risk: '저위험', speed: '0-3.0kt', feature: '직선투승왕복' },
|
||
];
|
||
|
||
const gearColumns: DataColumn<GearCode>[] = [
|
||
{ key: 'code', label: '코드', width: '60px', render: (v) => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> },
|
||
{ key: 'name', label: '어구명 (유형)', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||
render: (v) => {
|
||
const r = v as string;
|
||
const intent: BadgeIntent = r === '고위험' ? 'critical' : r === '중위험' ? 'warning' : 'success';
|
||
return <Badge intent={intent} size="xs">{r}</Badge>;
|
||
},
|
||
},
|
||
{ key: 'speed', label: '탐지 속도', width: '90px', align: 'center', render: (v) => <span className="text-label font-mono">{v as string}</span> },
|
||
{ key: 'feature', label: 'AI 피처 포인트', render: (v) => <span className="text-muted-foreground">{v as string}</span> },
|
||
];
|
||
|
||
const GEAR_PROFILES = [
|
||
{ type: '트롤 (Trawl)', desc: '"느리게, 꾸준히, 반복적으로"', color: '#3b82f6',
|
||
features: [{ k: 'Mean Speed', v: '2~5 kt' }, { k: 'Path Entropy', v: 'High' }, { k: 'Turn Angle', v: 'Medium' }, { k: 'Duration', v: 'Long' }] },
|
||
{ type: '자망 (Gillnet)', desc: '"멈추고, 기다리고, 다시 온다"', color: '#eab308',
|
||
features: [{ k: 'Mean Speed', v: '0~2 kt' }, { k: 'Dwell Time', v: 'Very High' }, { k: 'Low Speed', v: '>80%' }, { k: 'Revisit', v: 'High' }] },
|
||
{ type: '선망 (Purse Seine)', desc: '"빠르게 돌고 → 느려짐"', color: '#ef4444',
|
||
features: [{ k: 'High Speed', v: '>7 kt' }, { k: 'Circularity', v: 'High' }, { k: 'Speed Trans.', v: 'High' }, { k: 'Fleet', v: 'High' }] },
|
||
];
|
||
|
||
// ─── DAR-03 5종 어구 구조 비교 (FAO ISSCFG) ──────────
|
||
|
||
interface DAR03GearSummary {
|
||
no: string;
|
||
name: string;
|
||
faoCode: string;
|
||
mesh: string;
|
||
iuuRisk: '매우 높음' | '높음' | '중간' | '낮음~중간';
|
||
aisType: string;
|
||
gCodes: string;
|
||
}
|
||
|
||
const DAR03_GEAR_SUMMARY: DAR03GearSummary[] = [
|
||
{ no: '①', name: '저층 트롤', faoCode: 'OTB/TBB', mesh: '≥60mm', iuuRisk: '높음', aisType: '어선 AIS', gCodes: 'G-01, G-03' },
|
||
{ no: '②', name: '쌍끌이 트롤', faoCode: 'PTM', mesh: '≥56mm', iuuRisk: '매우 높음', aisType: '어선 AIS 2척', gCodes: 'G-02, G-06' },
|
||
{ no: '③', name: '스토우넷', faoCode: 'FYK', mesh: '≥55mm', iuuRisk: '중간', aisType: '어구 AIS 부표', gCodes: 'G-01, G-04, G-05' },
|
||
{ no: '④', name: '자망', faoCode: 'GNS/GND', mesh: '55~144mm', iuuRisk: '낮음~중간', aisType: '어구 AIS 부표', gCodes: 'G-03, G-05' },
|
||
{ no: '⑤', name: '통발·함정', faoCode: 'FPO', mesh: '탈출구 Ø≥8cm', iuuRisk: '중간', aisType: '어구 AIS 부표', gCodes: 'G-01, G-04' },
|
||
];
|
||
|
||
const DAR03_IUU_INTENT: Record<DAR03GearSummary['iuuRisk'], BadgeIntent> = {
|
||
'매우 높음': 'critical',
|
||
'높음': 'high',
|
||
'중간': 'warning',
|
||
'낮음~중간': 'info',
|
||
};
|
||
|
||
interface DAR03GearDetail {
|
||
no: string;
|
||
name: string;
|
||
nameEn: string;
|
||
image: string;
|
||
specs: { k: string; v: string }[];
|
||
gCodes: { code: string; desc: string }[];
|
||
}
|
||
|
||
const DAR03_GEAR_DETAILS: DAR03GearDetail[] = [
|
||
{
|
||
no: '①', name: '저층 트롤', nameEn: 'Bottom Trawl (OTB/TBB)', image: '/dar03/bottom-trawl.png',
|
||
specs: [
|
||
{ k: 'FAO 코드', v: 'OTB / TBB' },
|
||
{ k: '최소 망목', v: '≥ 60mm (마름모형)' },
|
||
{ k: '주요 어종', v: '참조기 · 갈치' },
|
||
{ k: '조업 속력', v: '2.5~4.5 knot' },
|
||
{ k: '항적 패턴', v: 'U형 회전 · 직선 왕복' },
|
||
{ k: 'AIS', v: '어선 AIS (어구 AIS 없음)' },
|
||
],
|
||
gCodes: [
|
||
{ code: 'G-01', desc: '허가 해역 외 트롤 → GIS 교차' },
|
||
{ code: 'G-03', desc: '미등록 어구 → label=1' },
|
||
],
|
||
},
|
||
{
|
||
no: '②', name: '쌍끌이 중층 트롤', nameEn: 'Pair Midwater Trawl (PTM)', image: '/dar03/pair-trawl.png',
|
||
specs: [
|
||
{ k: 'FAO 코드', v: 'PTM' },
|
||
{ k: '최소 망목', v: '≥ 56mm' },
|
||
{ k: '주요 어종', v: '전갱이 · 고등어 · 참조기' },
|
||
{ k: '선박 간격', v: '300~500m 유지' },
|
||
{ k: '조업 속력', v: '2~4 knot (2척 동기화)' },
|
||
{ k: 'AIS', v: '2척 어선 AIS 동기화' },
|
||
],
|
||
gCodes: [
|
||
{ code: 'G-02', desc: '금어기 내 공조 조업 탐지' },
|
||
{ code: 'G-06', desc: '2척 동기화 2시간+ → 공조' },
|
||
],
|
||
},
|
||
{
|
||
no: '③', name: '스토우넷 (안강망)', nameEn: 'Stow Net (FYK)', image: '/dar03/stow-net.png',
|
||
specs: [
|
||
{ k: 'FAO 코드', v: 'FYK' },
|
||
{ k: '최소 망목', v: '≥ 55mm (캔버스형)' },
|
||
{ k: '주요 어종', v: '참조기 · 갈치 · 실치' },
|
||
{ k: '설치 방식', v: '말뚝·닻으로 고정' },
|
||
{ k: 'AIS', v: '어구 AIS 부표 부착 의무' },
|
||
{ k: '탐지 지표', v: '위치 이탈·출현·소실 주기' },
|
||
],
|
||
gCodes: [
|
||
{ code: 'G-01', desc: '위치 편차 200m+ → 구역 외' },
|
||
{ code: 'G-04', desc: '신호 30분 내 반복 → MMSI 조작' },
|
||
{ code: 'G-05', desc: '이동 500m+ → 인위적 이동' },
|
||
],
|
||
},
|
||
{
|
||
no: '④', name: '자망', nameEn: 'Gillnet (GNS/GND)', image: '/dar03/gillnet.png',
|
||
specs: [
|
||
{ k: 'FAO 코드', v: 'GNS / GND' },
|
||
{ k: '최소 망목', v: '55~144mm (어종별 상이)' },
|
||
{ k: '참조기 기준', v: '55mm (황해)' },
|
||
{ k: '은돔 기준', v: '100mm' },
|
||
{ k: 'AIS', v: '어구 AIS 부표 부착' },
|
||
{ k: '탐지 지표', v: '미등록 여부·기간 이탈' },
|
||
],
|
||
gCodes: [
|
||
{ code: 'G-02', desc: '금어기 내 신호 출현 → label=1' },
|
||
{ code: 'G-03', desc: '등록DB 미매칭 → 불법 자망' },
|
||
{ code: 'G-05', desc: '조류 보정 후 500m+ → 이동' },
|
||
],
|
||
},
|
||
{
|
||
no: '⑤', name: '통발 · 함정', nameEn: 'Pot / Trap (FPO)', image: '/dar03/pot-trap.png',
|
||
specs: [
|
||
{ k: 'FAO 코드', v: 'FPO' },
|
||
{ k: '탈출구 (꽃게)', v: 'Ø ≥ 8cm 또는 높이 33mm' },
|
||
{ k: '탈출구 (참게)', v: '측면 30mm + 말단 7cm' },
|
||
{ k: '주요 어종', v: '꽃게 · 참게 · 장어' },
|
||
{ k: '미성어 방류율', v: '95% 이상 (탈출구 적용 시)' },
|
||
{ k: 'AIS', v: '어구 AIS 부표 부착' },
|
||
],
|
||
gCodes: [
|
||
{ code: 'G-01', desc: '허가 구역 외 설치 → GIS 교차' },
|
||
{ code: 'G-04', desc: '어선-어구 출현·소실 60분+ 불일치' },
|
||
],
|
||
},
|
||
];
|
||
|
||
interface DAR03AisSignal {
|
||
no: string;
|
||
name: string;
|
||
aisType: string;
|
||
normal: string[];
|
||
threshold: string[];
|
||
gCodes: string;
|
||
}
|
||
|
||
const DAR03_AIS_SIGNALS: DAR03AisSignal[] = [
|
||
{ no: '①', name: '저층 트롤', aisType: '어선 AIS (Class-A)',
|
||
normal: ['2.5~4.5 knot', 'U형 항적 반복'],
|
||
threshold: ['5 knot 이상 급가속', '금지 해역 진입'], gCodes: 'G-01, G-03' },
|
||
{ no: '②', name: '쌍끌이 트롤', aisType: '어선 AIS 2척',
|
||
normal: ['2~4 knot 동기화', '500m 간격 유지'],
|
||
threshold: ['동기화 2시간 이상', '동시 AIS 차단 30분+'], gCodes: 'G-02, G-06' },
|
||
{ no: '③', name: '스토우넷', aisType: '어구 AIS (Class-B)',
|
||
normal: ['위치 완전 고정', '신호 지속 출현'],
|
||
threshold: ['위치 편차 200m+', '출현·소실 30분 이내 반복'], gCodes: 'G-01, G-04, G-05' },
|
||
{ no: '④', name: '자망', aisType: '어구 AIS (Class-B)',
|
||
normal: ['위치 반고정', '조류에 따라 완만이동'],
|
||
threshold: ['등록 DB 미매칭', '금어기 내 신호 출현'], gCodes: 'G-02, G-03' },
|
||
{ no: '⑤', name: '통발', aisType: '어구 AIS (Class-B)',
|
||
normal: ['위치 완전 고정', '신호 지속'],
|
||
threshold: ['어선 접근·이탈 불일치 60분+', '구역 외 위치'], gCodes: 'G-01, G-04' },
|
||
];
|
||
|
||
// ─── ⑦ 7대 탐지 엔진 (불법조업 감시 알고리즘 v4.0) ───
|
||
|
||
interface DetectionEngine {
|
||
id: string;
|
||
name: string;
|
||
purpose: string;
|
||
input: string;
|
||
severity: string;
|
||
cooldown: string;
|
||
detail: string;
|
||
status: '운영중' | '테스트' | '개발중';
|
||
}
|
||
|
||
const DETECTION_ENGINES: DetectionEngine[] = [
|
||
{ id: '#1', name: '허가 유효성 검증', purpose: '미등록/미허가 중국어선 식별', input: 'AIS MMSI', severity: 'CRITICAL', cooldown: '24시간', detail: 'MMSI가 906척 허가 DB에 매핑되지 않으면 즉시 경보. 신규 MMSI 감지 시 미등록 선박 분류.', status: '운영중' },
|
||
{ id: '#2', name: '휴어기 조업 탐지', purpose: '허가 조업기간 외 조업행위 탐지', input: '위치, 속도, 허가기간', severity: 'CRITICAL', cooldown: '60분', detail: '선박별 최대 2개 조업기간 부여. 엔진#7 조업판정 결과 참조하여 단순 통과와 실제 조업 구분.', status: '운영중' },
|
||
{ id: '#3', name: '수역 이탈 감시', purpose: '업종별 허가수역 외 조업/체류 탐지', input: '위치(lat,lon)', severity: 'HIGH~CRITICAL', cooldown: '30분', detail: 'Ray-casting Point-in-Polygon 알고리즘. 수역II 787개 좌표점 등 복잡 폴리곤 정밀 판정. 비허가수역 HIGH, 전수역이탈 CRITICAL.', status: '운영중' },
|
||
{ id: '#4', name: '본선-부속선 분리 탐지', purpose: '2척식 저인망 쌍의 비정상 이격 감시', input: '쌍 위치, Haversine 거리', severity: 'HIGH~CRITICAL', cooldown: '60분', detail: '311쌍(622척) 매칭. 0~3NM 정상, 3~10NM HIGH(독립조업 의심), 10NM 초과 CRITICAL(긴급분리).', status: '운영중' },
|
||
{ id: '#5', name: '어획 할당량 초과 감시', purpose: '선박별 누적 어획량과 허가 할당량 비교', input: '어획량(톤)', severity: 'MEDIUM~CRITICAL', cooldown: '24시간', detail: '80% 도달 MEDIUM, 100% 초과 CRITICAL. PS 1,500톤, GN 23~52톤, FC 0톤(어획 없음).', status: '운영중' },
|
||
{ id: '#6', name: '환적 의심 탐지', purpose: '조업선-운반선(FC) 간 해상 환적 탐지', input: '근접거리, 속도, 시간', severity: 'HIGH', cooldown: '120분', detail: '3조건 동시충족: ①0.5NM 이하 근접 ②양쪽 2.0kn 이하 저속 ③30분 이상 지속. 신뢰도 0.6~0.95.', status: '운영중' },
|
||
{ id: '#7', name: '조업 행위 판정 (보조)', purpose: 'AIS 속도/침로로 조업 여부 추정', input: '속도, 침로 패턴', severity: '-', cooldown: '-', detail: 'PT 1.5~5.0kn, GN 0.5~3.0kn, PS 1.0~6.0kn, FC 0~2.0kn. 엔진#2·#3에서 참조.', status: '운영중' },
|
||
];
|
||
|
||
const TARGET_VESSELS = [
|
||
{ code: 'PT', name: '2척식 저인망 (본선)', count: 323, zones: 'II, III', period1: '01/01~04/15', period2: '10/16~12/31', speed: '1.5~5.0 kn' },
|
||
{ code: 'PT-S', name: '2척식 저인망 (부속)', count: 323, zones: 'II, III', period1: '01/01~04/15', period2: '10/16~12/31', speed: '1.5~5.0 kn' },
|
||
{ code: 'GN', name: '유망 (Gill Net)', count: 200, zones: 'II, III, IV', period1: '01/01~04/15', period2: '10/16~12/31', speed: '0.5~3.0 kn' },
|
||
{ code: 'OT', name: '1척식 저인망 (Otter)', count: 13, zones: 'II, III', period1: '01/01~04/15', period2: '10/16~12/31', speed: '2.0~5.5 kn' },
|
||
{ code: 'PS', name: '위망 (Purse Seine)', count: 16, zones: 'I, II, III, IV', period1: '02/01~06/01', period2: '09/01~12/31', speed: '1.0~6.0 kn' },
|
||
{ code: 'FC', name: '운반선 (Carrier)', count: 31, zones: 'I, II, III, IV', period1: '01/01~12/31', period2: '-', speed: '0~2.0 kn' },
|
||
];
|
||
|
||
const ALARM_SEVERITY = [
|
||
{ level: 'CRITICAL', label: '긴급', color: '#ef4444', desc: '즉시 대응. 미등록·휴어기·할당초과·전수역이탈' },
|
||
{ level: 'HIGH', label: '높음', color: '#f97316', desc: '1시간 내 확인. 수역이탈·쌍분리·AIS소실·환적' },
|
||
{ level: 'MEDIUM', label: '보통', color: '#eab308', desc: '당일 확인. 할당량 80% 경고' },
|
||
{ level: 'LOW', label: '낮음', color: '#3b82f6', desc: '참고. 경미한 속도 이상' },
|
||
{ level: 'INFO', label: '정보', color: '#6b7280', desc: '로그 기록용. 정상 조업 패턴' },
|
||
];
|
||
|
||
// ─── 메인 컴포넌트 ──────────────────────
|
||
|
||
export function AIModelManagement() {
|
||
const { t } = useTranslation('ai');
|
||
const { t: tcCommon } = useTranslation('common');
|
||
const lang = useSettingsStore((s) => s.language);
|
||
const [tab, setTab] = useState<Tab>('registry');
|
||
const [rules, setRules] = useState(defaultRules);
|
||
|
||
const toggleRule = (i: number) => setRules((prev) => prev.map((r, idx) => idx === i ? { ...r, enabled: !r.enabled } : r));
|
||
const currentModel = MODELS.find((m) => m.status === '운영중')!;
|
||
|
||
return (
|
||
<PageContainer>
|
||
<PageHeader
|
||
icon={Brain}
|
||
iconColor="text-purple-400"
|
||
title={t('modelManagement.title')}
|
||
description={t('modelManagement.desc')}
|
||
demo
|
||
actions={
|
||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||
<span className="text-[10px] text-green-400 font-bold">운영 모델: {currentModel.version}</span>
|
||
<span className="text-[10px] text-hint">Accuracy {currentModel.accuracy}%</span>
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{/* KPI */}
|
||
<div className="flex gap-2">
|
||
{[
|
||
{ label: '탐지 정확도', value: `${currentModel.accuracy}%`, icon: Target, color: 'text-green-400', bg: 'bg-green-500/10' },
|
||
{ label: '오탐률', value: `${currentModel.falseAlarm}%`, icon: AlertTriangle, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
||
{ label: '평균 리드타임', value: '12min', icon: Clock, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
|
||
{ label: '학습 데이터', value: currentModel.trainData, icon: Database, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||
{ label: '모델 버전', value: MODELS.length + '개', icon: GitBranch, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||
{ label: '탐지 규칙', value: rules.filter((r) => r.enabled).length + '/' + rules.length, icon: Shield, color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
||
].map((kpi) => (
|
||
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
||
<kpi.icon className={`w-3.5 h-3.5 ${kpi.color}`} />
|
||
</div>
|
||
<span className={`text-base font-bold ${kpi.color}`}>{kpi.value}</span>
|
||
<span className="text-[9px] text-hint">{kpi.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 탭 */}
|
||
<div className="flex gap-1">
|
||
{[
|
||
{ key: 'registry' as Tab, icon: GitBranch, label: '모델 레지스트리' },
|
||
{ key: 'rules' as Tab, icon: Settings, label: '탐지 규칙 관리' },
|
||
{ key: 'features' as Tab, icon: Cpu, label: '피처 엔지니어링' },
|
||
{ key: 'pipeline' as Tab, icon: Activity, label: '학습 파이프라인' },
|
||
{ key: 'monitoring' as Tab, icon: BarChart3, label: '성능 모니터링' },
|
||
{ key: 'gear' as Tab, icon: Anchor, label: '어구 탐지 모델' },
|
||
{ key: 'engines' as Tab, icon: Shield, label: '7대 탐지 엔진' },
|
||
{ key: 'api' as Tab, icon: Globe, label: '예측 결과 API' },
|
||
].map((t) => (
|
||
<button type="button" key={t.key} onClick={() => setTab(t.key)}
|
||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs transition-colors ${tab === t.key ? 'bg-purple-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`}>
|
||
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── ① 모델 레지스트리 ── */}
|
||
{tab === 'registry' && (
|
||
<div className="space-y-3">
|
||
{/* 업데이트 알림 */}
|
||
<div className="bg-blue-950/20 border border-blue-900/30 rounded-xl p-4 flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<Zap className="w-5 h-5 text-blue-400 shrink-0" />
|
||
<div>
|
||
<div className="text-sm text-blue-300 font-bold">새로운 모델 v2.4.0 테스트 완료</div>
|
||
<div className="text-[10px] text-muted-foreground">정확도 93.2% (+3.1%) · 오탐률 7.8% (-2.1%) · 다크베셀 탐지 강화</div>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0">운영 배포</button>
|
||
</div>
|
||
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
|
||
</div>
|
||
)}
|
||
|
||
{/* ── ② 탐지 규칙 관리 ── */}
|
||
{tab === 'rules' && (
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{/* 규칙 ON/OFF */}
|
||
<div className="col-span-2 space-y-2">
|
||
{rules.map((rule, i) => (
|
||
<Card key={i} className="bg-surface-raised border-border">
|
||
<CardContent className="p-3 flex items-center gap-4">
|
||
<button type="button" role="switch" aria-checked={rule.enabled ? 'true' : 'false'} aria-label={rule.name} onClick={() => toggleRule(i)}
|
||
className={`w-10 h-5 rounded-full transition-colors relative shrink-0 ${rule.enabled ? 'bg-blue-600' : 'bg-switch-background'}`}>
|
||
<div className="w-4 h-4 bg-white rounded-full absolute top-0.5 transition-all shadow-sm" style={{ left: rule.enabled ? '22px' : '2px' }} />
|
||
</button>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[12px] font-bold text-heading">{rule.name}</span>
|
||
<Badge intent="purple" size="xs">{rule.model}</Badge>
|
||
</div>
|
||
<div className="text-[10px] text-hint mt-0.5">{rule.desc}</div>
|
||
</div>
|
||
<div className="flex items-center gap-3 shrink-0">
|
||
<div className="text-right">
|
||
<div className="text-[9px] text-hint">정확도</div>
|
||
<div className="text-[12px] font-bold text-heading">{rule.accuracy}%</div>
|
||
</div>
|
||
<div className="w-20 h-1.5 bg-switch-background rounded-full overflow-hidden">
|
||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${rule.accuracy}%` }} />
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-[9px] text-hint">가중치</div>
|
||
<div className="text-[12px] font-bold text-cyan-400">{rule.weight}%</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
{/* 가중치 합계 */}
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-label mb-4 flex items-center gap-1.5"><Zap className="w-4 h-4 text-yellow-400" />위험도 가중치</div>
|
||
<div className="space-y-4">
|
||
{rules.filter((r) => r.enabled).map((r, i) => (
|
||
<div key={i}>
|
||
<div className="flex justify-between text-[10px] mb-1"><span className="text-muted-foreground">{r.name}</span><span className="text-heading font-bold">{r.weight}%</span></div>
|
||
<div className="relative h-2 bg-switch-background rounded-full">
|
||
<div className="h-2 bg-gradient-to-r from-slate-500 to-white rounded-full" style={{ width: `${r.weight}%` }} />
|
||
<div className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full border-2 border-slate-400 shadow" style={{ left: `calc(${r.weight}% - 6px)` }} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div className="flex justify-between text-[11px] pt-3 border-t border-border">
|
||
<span className="text-muted-foreground">합계</span>
|
||
<span className="text-heading font-bold">{rules.filter((r) => r.enabled).reduce((s, r) => s + r.weight, 0)}%</span>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── ③ 피처 엔지니어링 ── */}
|
||
{tab === 'features' && (
|
||
<div className="grid grid-cols-5 gap-3">
|
||
{FEATURE_CATEGORIES.map((cat) => (
|
||
<Card key={cat.cat} className="bg-surface-raised border-border">
|
||
<CardContent className="p-3">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<div className="p-1.5 rounded-lg" style={{ backgroundColor: `${cat.color}15` }}>
|
||
<cat.icon className="w-4 h-4" style={{ color: cat.color }} />
|
||
</div>
|
||
<div>
|
||
<div className="text-[11px] font-bold text-heading">{cat.cat}</div>
|
||
<div className="text-[9px] text-hint">{cat.features.length} features</div>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{cat.features.map((f) => (
|
||
<div key={f.name} className="flex items-center justify-between px-2 py-1.5 bg-surface-overlay rounded-lg">
|
||
<div>
|
||
<div className="text-[10px] text-label font-medium">{f.name}</div>
|
||
<div className="text-[9px] text-hint">{f.desc}</div>
|
||
</div>
|
||
{f.unit && <span className="text-[8px] text-hint shrink-0">{f.unit}</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── ④ 학습 파이프라인 ── */}
|
||
{tab === 'pipeline' && (
|
||
<div className="space-y-3">
|
||
{/* 파이프라인 스테이지 */}
|
||
<div className="flex gap-2">
|
||
{PIPELINE_STAGES.map((stage, i) => {
|
||
const stColor = stage.status === '정상' ? 'text-green-400' : stage.status === '진행중' ? 'text-blue-400' : 'text-hint';
|
||
return (
|
||
<div key={stage.stage} className="flex-1 flex items-start gap-2">
|
||
<Card className="flex-1 bg-surface-raised border-border">
|
||
<CardContent className="p-3">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<div className="p-1.5 rounded-lg" style={{ backgroundColor: `${stage.color}15` }}>
|
||
<stage.icon className="w-4 h-4" style={{ color: stage.color }} />
|
||
</div>
|
||
<div>
|
||
<div className="text-[10px] font-bold text-heading">{stage.stage}</div>
|
||
<div className={`text-[9px] font-medium ${stColor}`}>{stage.status}</div>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
{stage.items.map((item) => (
|
||
<div key={item} className="text-[9px] text-hint flex items-center gap-1">
|
||
<div className="w-1 h-1 rounded-full bg-muted" />
|
||
{item}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
{i < PIPELINE_STAGES.length - 1 && (
|
||
<ChevronRight className="w-4 h-4 text-hint mt-6 shrink-0" />
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 자가학습 루프 */}
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3">Self-Learning Cycle — 지속 학습 루프</div>
|
||
<div className="flex items-center justify-between gap-4">
|
||
{[
|
||
{ step: '1. 신호 수집', desc: '단속 결과, 오탐/미탐 사례 실시간 수집', color: '#3b82f6' },
|
||
{ step: '2. 데이터 축적', desc: '정제 및 라벨링 → 학습 데이터셋 자동 갱신', color: '#8b5cf6' },
|
||
{ step: '3. 자동 재학습', desc: 'AutoML 기반 재학습 및 성능 검증', color: '#ef4444' },
|
||
{ step: '4. 모델 개선', desc: '검증 모델 배포 → 탐지 정확도 향상', color: '#10b981' },
|
||
].map((s, i) => (
|
||
<div key={i} className="flex-1 text-center">
|
||
<div className="w-10 h-10 rounded-full mx-auto mb-2 flex items-center justify-center" style={{ backgroundColor: `${s.color}15`, border: `2px solid ${s.color}40` }}>
|
||
<span className="text-[12px] font-bold" style={{ color: s.color }}>{i + 1}</span>
|
||
</div>
|
||
<div className="text-[10px] font-bold text-heading">{s.step}</div>
|
||
<div className="text-[9px] text-hint mt-0.5">{s.desc}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── ⑤ 성능 모니터링 ── */}
|
||
{tab === 'monitoring' && (
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{/* 정확도 추이 */}
|
||
<Card className="col-span-2 bg-surface-raised border-border">
|
||
<CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-label mb-3">모델 성능 추이</div>
|
||
<EcAreaChart
|
||
data={PERF_HISTORY}
|
||
xKey="month"
|
||
series={[
|
||
{ key: 'accuracy', name: 'Accuracy', color: '#22c55e' },
|
||
{ key: 'recall', name: 'Recall', color: '#3b82f6' },
|
||
{ key: 'f1', name: 'F1', color: '#a855f7' },
|
||
]}
|
||
height={220}
|
||
yAxisDomain={[70, 100]}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 탐지 유형별 비율 */}
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-label mb-3">탐지 유형별 비율</div>
|
||
<EcPieChart
|
||
data={DETECTION_BY_TYPE.map(d => ({ name: d.name, value: d.value, color: d.color }))}
|
||
height={160}
|
||
innerRadius={35}
|
||
outerRadius={65}
|
||
/>
|
||
<div className="space-y-1 mt-2">
|
||
{DETECTION_BY_TYPE.map((d) => (
|
||
<div key={d.name} className="flex items-center justify-between text-[10px]">
|
||
<div className="flex items-center gap-1.5">
|
||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: d.color }} />
|
||
<span className="text-muted-foreground">{d.name}</span>
|
||
</div>
|
||
<span className="text-heading font-bold">{d.value}%</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 오탐률 추이 */}
|
||
<Card className="col-span-2 bg-surface-raised border-border">
|
||
<CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-label mb-3">오탐률 (False Alarm Rate) 추이</div>
|
||
<EcBarChart
|
||
data={PERF_HISTORY}
|
||
xKey="month"
|
||
series={[{ key: 'falseAlarm', name: '오탐률 %' }]}
|
||
height={160}
|
||
itemColors={PERF_HISTORY.map(d => d.falseAlarm < 10 ? '#22c55e' : d.falseAlarm < 15 ? '#eab308' : '#ef4444')}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* KPI 목표 */}
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-label mb-3">KPI 목표 달성</div>
|
||
<div className="space-y-4">
|
||
{[
|
||
{ label: '탐지 정확도', current: 93.2, target: 90, unit: '%', color: '#22c55e' },
|
||
{ label: '오탐률', current: 7.8, target: 10, unit: '%', color: '#eab308', reverse: true },
|
||
{ label: '리드타임', current: 12, target: 15, unit: 'min', color: '#06b6d4', reverse: true },
|
||
].map((kpi) => {
|
||
const achieved = kpi.reverse ? kpi.current <= kpi.target : kpi.current >= kpi.target;
|
||
return (
|
||
<div key={kpi.label}>
|
||
<div className="flex justify-between text-[10px] mb-1">
|
||
<span className="text-muted-foreground">{kpi.label}</span>
|
||
<span className={achieved ? 'text-green-400 font-bold' : 'text-red-400 font-bold'}>
|
||
{kpi.current}{kpi.unit} {achieved ? '(달성)' : `(목표 ${kpi.target}${kpi.unit})`}
|
||
</span>
|
||
</div>
|
||
<div className="h-2 bg-switch-background rounded-full overflow-hidden">
|
||
<div className="h-full rounded-full" style={{
|
||
width: `${Math.min(100, kpi.reverse ? (kpi.target / Math.max(kpi.current, 1)) * 100 : (kpi.current / kpi.target) * 100)}%`,
|
||
backgroundColor: kpi.color,
|
||
}} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── ⑥ 어구 탐지 모델 ── */}
|
||
{tab === 'gear' && (
|
||
<div className="space-y-3">
|
||
{/* GB/T 5147 어구 코드 테이블 */}
|
||
<DataTable data={GEAR_CODES} columns={gearColumns} pageSize={10}
|
||
title="GB/T 5147 주요 어구 코드 매핑" searchPlaceholder="코드, 어구명 검색..."
|
||
searchKeys={['code', 'name', 'feature']} exportFilename="어구코드" />
|
||
|
||
{/* 어구별 피처 프로파일 */}
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{GEAR_PROFILES.map((gp) => (
|
||
<Card key={gp.type} className="bg-surface-raised border-border">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: gp.color }} />
|
||
<div className="text-[12px] font-bold text-heading">{gp.type}</div>
|
||
</div>
|
||
<div className="text-[10px] text-hint italic mb-3">{gp.desc}</div>
|
||
<div className="space-y-2">
|
||
{gp.features.map((f) => (
|
||
<div key={f.k} className="flex justify-between text-[10px] px-2 py-1.5 bg-surface-overlay rounded-lg">
|
||
<span className="text-muted-foreground">{f.k}</span>
|
||
<span className="text-heading font-medium">{f.v}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── DAR-03 5종 어구 구조 비교 ── */}
|
||
<div className="bg-indigo-950/20 border border-indigo-900/30 rounded-xl p-4 flex items-center gap-4">
|
||
<Info className="w-5 h-5 text-indigo-400 shrink-0" />
|
||
<div className="flex-1">
|
||
<div className="text-[12px] font-bold text-indigo-300">DAR-03 · 5종 어구 구조 비교 (FAO ISSCFG)</div>
|
||
<div className="text-[10px] text-hint mt-0.5">
|
||
불법 어망·어구 탐지 참고자료 — FAO 국제 어구 분류 기준 · Wang et al.(2022) 논문 기반 · G-01~G-06 탐지 코드 연계
|
||
</div>
|
||
</div>
|
||
<Badge intent="purple" size="sm">참고자료</Badge>
|
||
</div>
|
||
|
||
{/* 5종 어구 특성 비교 요약 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Anchor className="w-4 h-4 text-cyan-400" />
|
||
5종 어구 특성 비교 요약
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<table className="w-full text-[10px]">
|
||
<thead>
|
||
<tr className="text-hint border-b border-border">
|
||
<th className="text-left py-2 px-2">어구</th>
|
||
<th className="text-center py-2">FAO 코드</th>
|
||
<th className="text-center py-2">최소 망목</th>
|
||
<th className="text-center py-2">IUU 위험도</th>
|
||
<th className="text-center py-2">AIS 부착</th>
|
||
<th className="text-left py-2 px-2">주요 G코드</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{DAR03_GEAR_SUMMARY.map((g) => (
|
||
<tr key={g.no} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||
<td className="py-2 px-2">
|
||
<span className="text-cyan-400 font-mono mr-2">{g.no}</span>
|
||
<span className="text-heading font-medium">{g.name}</span>
|
||
</td>
|
||
<td className="py-2 text-center text-label font-mono">{g.faoCode}</td>
|
||
<td className="py-2 text-center text-muted-foreground font-mono">{g.mesh}</td>
|
||
<td className="py-2 text-center">
|
||
<Badge intent={DAR03_IUU_INTENT[g.iuuRisk]} size="xs">{g.iuuRisk}</Badge>
|
||
</td>
|
||
<td className="py-2 text-center text-muted-foreground">{g.aisType}</td>
|
||
<td className="py-2 px-2 text-cyan-400 font-mono text-[9px]">{g.gCodes}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 어구별 구조 도식 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Eye className="w-4 h-4 text-blue-400" />
|
||
어구별 구조 도식 비교
|
||
</CardTitle>
|
||
<p className="text-[9px] text-hint italic">
|
||
※ FAO 어구 분류 기준 및 Wang et al.(2022) 논문 기반 개념도. 임계값은 사업 착수 후 해양경찰청 실무 데이터 분석으로 최종 확정.
|
||
</p>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{DAR03_GEAR_DETAILS.map((g) => (
|
||
<Card key={g.no} className="bg-surface-raised border-border">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<span className="text-cyan-400 font-mono font-bold text-sm">{g.no}</span>
|
||
<div className="flex-1">
|
||
<div className="text-[12px] font-bold text-heading">{g.name}</div>
|
||
<div className="text-[9px] text-hint">{g.nameEn}</div>
|
||
</div>
|
||
</div>
|
||
<div className="bg-surface-overlay rounded-lg p-2 mb-3 flex items-center justify-center">
|
||
<img src={g.image} alt={g.nameEn} className="w-full h-auto max-h-48 object-contain" />
|
||
</div>
|
||
<div className="space-y-1 mb-3">
|
||
{g.specs.map((s) => (
|
||
<div key={s.k} className="flex justify-between text-[10px] px-2 py-1 bg-surface-overlay rounded">
|
||
<span className="text-muted-foreground">{s.k}</span>
|
||
<span className="text-heading font-medium">{s.v}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="border-t border-border pt-2">
|
||
<div className="text-[9px] text-hint mb-1.5 font-medium">G코드 연계</div>
|
||
<div className="space-y-1">
|
||
{g.gCodes.map((gc) => (
|
||
<div key={gc.code} className="flex items-start gap-2 text-[9px]">
|
||
<Badge intent="cyan" size="xs">{gc.code}</Badge>
|
||
<span className="text-muted-foreground flex-1">{gc.desc}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* AIS 신호 특성 및 이상 판정 기준 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Radio className="w-4 h-4 text-purple-400" />
|
||
어구별 AIS 신호 특성 및 이상 판정 기준
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<table className="w-full text-[10px]">
|
||
<thead>
|
||
<tr className="text-hint border-b border-border">
|
||
<th className="text-left py-2 px-2">어구</th>
|
||
<th className="text-left py-2">AIS 유형</th>
|
||
<th className="text-left py-2">정상 신호 특성</th>
|
||
<th className="text-left py-2">이상 탐지 임계값</th>
|
||
<th className="text-left py-2 px-2">G코드</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{DAR03_AIS_SIGNALS.map((s) => (
|
||
<tr key={s.no} className="border-b border-border/50 hover:bg-surface-overlay transition-colors align-top">
|
||
<td className="py-2.5 px-2">
|
||
<span className="text-cyan-400 font-mono mr-1">{s.no}</span>
|
||
<span className="text-heading font-medium">{s.name}</span>
|
||
</td>
|
||
<td className="py-2.5 text-label">{s.aisType}</td>
|
||
<td className="py-2.5">
|
||
<ul className="space-y-0.5">
|
||
{s.normal.map((n) => (
|
||
<li key={n} className="text-muted-foreground flex items-start gap-1">
|
||
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
|
||
<span>{n}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</td>
|
||
<td className="py-2.5">
|
||
<ul className="space-y-0.5">
|
||
{s.threshold.map((th) => (
|
||
<li key={th} className="text-muted-foreground flex items-start gap-1">
|
||
<AlertTriangle className="w-3 h-3 text-orange-400 shrink-0 mt-0.5" />
|
||
<span>{th}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</td>
|
||
<td className="py-2.5 px-2 text-cyan-400 font-mono text-[9px]">{s.gCodes}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── ⑦ 7대 탐지 엔진 ── */}
|
||
{tab === 'engines' && (
|
||
<div className="space-y-4">
|
||
{/* 개요 배너 */}
|
||
<div className="bg-indigo-950/20 border border-indigo-900/30 rounded-xl p-4 flex items-center gap-4">
|
||
<Shield className="w-8 h-8 text-indigo-400 shrink-0" />
|
||
<div>
|
||
<div className="text-sm font-bold text-indigo-300">불법조업 감시 알고리즘 v4.0 — 7대 핵심 탐지 엔진</div>
|
||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||
대상: 중국 허가어선 906척 (497개 소유주) · 특정어업수역 I~IV · AIS 실시간 데이터 입력 → 7대 엔진 순차 실행 → AlarmEvent 생성
|
||
</div>
|
||
</div>
|
||
<div className="ml-auto flex gap-3 shrink-0 text-center">
|
||
<div><div className="text-lg font-bold text-heading">906</div><div className="text-[9px] text-hint">허가 선박</div></div>
|
||
<div><div className="text-lg font-bold text-cyan-400">7</div><div className="text-[9px] text-hint">탐지 엔진</div></div>
|
||
<div><div className="text-lg font-bold text-green-400">5</div><div className="text-[9px] text-hint">업종 분류</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 7대 엔진 카드 */}
|
||
<div className="space-y-2">
|
||
{DETECTION_ENGINES.map((eng) => {
|
||
return (
|
||
<Card key={eng.id} className="bg-surface-raised border-border">
|
||
<CardContent className="p-3 flex items-start gap-4">
|
||
{/* 번호 */}
|
||
<div className="w-10 h-10 rounded-lg bg-indigo-500/15 border border-indigo-500/20 flex items-center justify-center shrink-0">
|
||
<span className="text-indigo-400 font-bold text-sm">{eng.id}</span>
|
||
</div>
|
||
{/* 내용 */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className="text-[12px] font-bold text-heading">{eng.name}</span>
|
||
<Badge intent={getStatusIntent(eng.status)} size="xs">{eng.status}</Badge>
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground mb-1.5">{eng.purpose}</div>
|
||
<div className="text-[10px] text-hint leading-relaxed">{eng.detail}</div>
|
||
</div>
|
||
{/* 우측 정보 */}
|
||
<div className="flex items-center gap-4 shrink-0 text-right">
|
||
<div>
|
||
<div className="text-[9px] text-hint">입력</div>
|
||
<div className="text-[10px] text-label">{eng.input}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-[9px] text-hint">심각도</div>
|
||
<Badge intent={getEngineSeverityIntent(eng.severity)} size="sm">
|
||
{getEngineSeverityLabel(eng.severity, tcCommon, lang)}
|
||
</Badge>
|
||
</div>
|
||
<div>
|
||
<div className="text-[9px] text-hint">쿨다운</div>
|
||
<div className="text-[10px] text-label font-mono">{eng.cooldown}</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 하단 2칸: 대상 선박 + 알람 등급 */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{/* 대상 선박 업종 */}
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||
<Ship className="w-4 h-4 text-cyan-400" />대상 선박 현황 (906척, 6개 업종)
|
||
</div>
|
||
<table className="w-full text-[10px]">
|
||
<thead>
|
||
<tr className="border-b border-border text-hint">
|
||
<th className="py-1.5 text-left font-medium">업종</th>
|
||
<th className="py-1.5 text-left font-medium">업종명</th>
|
||
<th className="py-1.5 text-right font-medium">척수</th>
|
||
<th className="py-1.5 text-left font-medium pl-3">허가수역</th>
|
||
<th className="py-1.5 text-left font-medium">조업기간1</th>
|
||
<th className="py-1.5 text-left font-medium">조업기간2</th>
|
||
<th className="py-1.5 text-left font-medium">조업속도</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{TARGET_VESSELS.map((v) => (
|
||
<tr key={v.code} className="border-b border-border">
|
||
<td className="py-1.5 text-cyan-400 font-mono font-bold">{v.code}</td>
|
||
<td className="py-1.5 text-label">{v.name}</td>
|
||
<td className="py-1.5 text-heading font-bold text-right">{v.count}</td>
|
||
<td className="py-1.5 text-muted-foreground pl-3">{v.zones}</td>
|
||
<td className="py-1.5 text-muted-foreground font-mono">{v.period1}</td>
|
||
<td className="py-1.5 text-muted-foreground font-mono">{v.period2}</td>
|
||
<td className="py-1.5 text-muted-foreground font-mono">{v.speed}</td>
|
||
</tr>
|
||
))}
|
||
<tr className="border-t border-border">
|
||
<td className="py-1.5 text-hint font-bold" colSpan={2}>합계</td>
|
||
<td className="py-1.5 text-heading font-bold text-right">906</td>
|
||
<td className="py-1.5 text-hint pl-3" colSpan={4}>497개 소유주</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 알람 심각도 체계 */}
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||
<AlertTriangle className="w-4 h-4 text-yellow-400" />알람 심각도 체계
|
||
</div>
|
||
<div className="space-y-2">
|
||
{ALARM_SEVERITY.map((a) => (
|
||
<div key={a.level} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-surface-overlay">
|
||
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: a.color }} />
|
||
<div className="w-16 shrink-0">
|
||
<span className="text-[10px] font-bold" style={{ color: a.color }}>{a.level}</span>
|
||
</div>
|
||
<span className="text-[10px] text-label font-medium w-10">{a.label}</span>
|
||
<span className="text-[10px] text-hint flex-1">{a.desc}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 쿨다운 요약 */}
|
||
<div className="mt-3 pt-3 border-t border-border">
|
||
<div className="text-[10px] font-bold text-muted-foreground mb-2">알람 쿨다운 (중복 방지)</div>
|
||
<div className="grid grid-cols-2 gap-1 text-[9px]">
|
||
{[
|
||
['수역이탈', '30분'], ['쌍분리', '60분'], ['휴어기', '60분'], ['환적', '120분'],
|
||
['AIS소실', '360분'], ['할당량/미등록', '24시간'],
|
||
].map(([k, v]) => (
|
||
<div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded">
|
||
<span className="text-hint">{k}</span>
|
||
<span className="text-label font-mono">{v}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── ⑧ 예측 결과 API ── */}
|
||
{tab === 'api' && (
|
||
<div className="space-y-4">
|
||
{/* 개요 */}
|
||
<div className="bg-emerald-950/20 border border-emerald-900/30 rounded-xl p-4 flex items-center gap-4">
|
||
<Globe className="w-8 h-8 text-emerald-400 shrink-0" />
|
||
<div className="flex-1">
|
||
<div className="text-sm font-bold text-emerald-300">RFP-04 · 예측 결과 API 제공</div>
|
||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||
격자(Grid) · 해역(Zone) · 시간(Time) 단위로 예측 결과를 저장하고, SFR-06(위험도 지도), SFR-09(불법어선 탐지) 등 후속 서비스에서 RESTful API로 활용
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-4 shrink-0 text-center">
|
||
<div><div className="text-lg font-bold text-emerald-400">12</div><div className="text-[9px] text-hint">API 엔드포인트</div></div>
|
||
<div><div className="text-lg font-bold text-cyan-400">3</div><div className="text-[9px] text-hint">저장 단위</div></div>
|
||
<div><div className="text-lg font-bold text-blue-400">99.7%</div><div className="text-[9px] text-hint">가용률</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 저장 단위 3종 */}
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{[
|
||
{ unit: '격자 (Grid)', icon: Layers, color: '#3b82f6', desc: '1km × 1km 격자 단위', store: 'tb_pred_grid',
|
||
fields: ['grid_id', 'lat_center / lon_center', 'risk_score (0~100)', 'illegal_prob', 'dark_vessel_count', 'predicted_at', 'model_version'],
|
||
sample: { grid_id: 'G-37120-12463', lat: 37.120, lon: 124.630, risk: 87.5, prob: 0.92, dark: 3, time: '2026-04-03T09:00Z', ver: 'v2.3.1' },
|
||
},
|
||
{ unit: '해역 (Zone)', icon: Globe, color: '#10b981', desc: '특정어업수역 I~IV + EEZ', store: 'tb_pred_zone',
|
||
fields: ['zone_id (I/II/III/IV/EEZ)', 'zone_name', 'total_risk_score', 'vessel_count', 'violation_count', 'top_threat_type', 'predicted_at'],
|
||
sample: { grid_id: 'ZONE-II', lat: null, lon: null, risk: 72.3, prob: null, dark: null, time: '2026-04-03T09:00Z', ver: 'v2.3.1' },
|
||
},
|
||
{ unit: '시간 (Time)', icon: Clock, color: '#f59e0b', desc: '1시간 / 6시간 / 24시간 집계', store: 'tb_pred_time',
|
||
fields: ['time_bucket (1h/6h/24h)', 'start_time / end_time', 'avg_risk_score', 'max_risk_score', 'total_alarms', 'detection_count', 'model_version'],
|
||
sample: { grid_id: 'T-2026040309-1H', lat: null, lon: null, risk: 65.8, prob: null, dark: null, time: '2026-04-03T09:00Z', ver: 'v2.3.1' },
|
||
},
|
||
].map((s) => (
|
||
<Card key={s.unit} className="bg-surface-raised border-border">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<div className="p-1.5 rounded-lg" style={{ backgroundColor: `${s.color}15` }}>
|
||
<s.icon className="w-4 h-4" style={{ color: s.color }} />
|
||
</div>
|
||
<div>
|
||
<div className="text-[12px] font-bold text-heading">{s.unit}</div>
|
||
<div className="text-[9px] text-hint">{s.desc}</div>
|
||
</div>
|
||
</div>
|
||
<div className="text-[9px] text-hint mb-2 font-mono">{s.store}</div>
|
||
<div className="space-y-1">
|
||
{s.fields.map((f) => (
|
||
<div key={f} className="flex items-center gap-1.5 text-[10px]">
|
||
<div className="w-1 h-1 rounded-full" style={{ backgroundColor: s.color }} />
|
||
<span className="text-muted-foreground font-mono">{f}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* API 엔드포인트 목록 */}
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||
<Code className="w-4 h-4 text-cyan-400" />
|
||
RESTful API 엔드포인트
|
||
</div>
|
||
<table className="w-full text-[10px] table-fixed">
|
||
<colgroup>
|
||
<col style={{ width: '7%' }} />
|
||
<col style={{ width: '30%' }} />
|
||
<col style={{ width: '8%' }} />
|
||
<col style={{ width: '30%' }} />
|
||
<col style={{ width: '15%' }} />
|
||
<col style={{ width: '10%' }} />
|
||
</colgroup>
|
||
<thead>
|
||
<tr className="border-b border-border text-hint">
|
||
<th className="py-2 text-left font-medium">Method</th>
|
||
<th className="py-2 text-left font-medium">Endpoint</th>
|
||
<th className="py-2 text-left font-medium">단위</th>
|
||
<th className="py-2 text-left font-medium">설명</th>
|
||
<th className="py-2 text-left font-medium">활용 SFR</th>
|
||
<th className="py-2 text-center font-medium">상태</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{[
|
||
{ method: 'GET', endpoint: '/api/v1/predictions/grid', unit: '격자', desc: '격자별 위험도 예측 결과 조회', sfr: 'SFR-06 위험도', status: '운영' },
|
||
{ method: 'GET', endpoint: '/api/v1/predictions/grid/{id}', unit: '격자', desc: '특정 격자 상세 예측 결과', sfr: 'SFR-06', status: '운영' },
|
||
{ method: 'GET', endpoint: '/api/v1/predictions/grid/heatmap', unit: '격자', desc: '히트맵용 전체 격자 위험도', sfr: 'SFR-06 히트맵', status: '운영' },
|
||
{ method: 'GET', endpoint: '/api/v1/predictions/zone', unit: '해역', desc: '해역별 위험도 집계 조회', sfr: 'SFR-06, SFR-09', status: '운영' },
|
||
{ method: 'GET', endpoint: '/api/v1/predictions/zone/{id}', unit: '해역', desc: '특정 수역 상세 (위협 유형 포함)', sfr: 'SFR-09 탐지', status: '운영' },
|
||
{ method: 'GET', endpoint: '/api/v1/predictions/zone/{id}/vessels', unit: '해역', desc: '수역 내 의심 선박 목록', sfr: 'SFR-09', status: '운영' },
|
||
{ method: 'GET', endpoint: '/api/v1/predictions/time', unit: '시간', desc: '시간대별 위험도 추이', sfr: 'SFR-06 추이', status: '운영' },
|
||
{ method: 'GET', endpoint: '/api/v1/predictions/time/forecast', unit: '시간', desc: '향후 6/12/24시간 예측', sfr: 'SFR-06 예보', status: '테스트' },
|
||
{ method: 'GET', endpoint: '/api/v1/predictions/vessel/{mmsi}', unit: '선박', desc: '특정 선박 위험도 이력', sfr: 'SFR-09', status: '운영' },
|
||
{ method: 'GET', endpoint: '/api/v1/predictions/alarms', unit: '알람', desc: '예측 기반 알람 목록', sfr: 'SFR-09 경보', status: '운영' },
|
||
{ method: 'POST', endpoint: '/api/v1/predictions/run', unit: '실행', desc: '수동 예측 실행 트리거', sfr: 'SFR-04', status: '운영' },
|
||
{ method: 'GET', endpoint: '/api/v1/predictions/status', unit: '상태', desc: '예측 엔진 상태·버전 정보', sfr: 'SFR-04', status: '운영' },
|
||
].map((api, i) => (
|
||
<tr key={i} className="border-b border-border hover:bg-surface-overlay">
|
||
<td className="py-1.5">
|
||
<Badge intent={api.method === 'GET' ? 'success' : 'info'} size="xs">{api.method}</Badge>
|
||
</td>
|
||
<td className="py-1.5 font-mono text-cyan-400">{api.endpoint}</td>
|
||
<td className="py-1.5 text-hint">{api.unit}</td>
|
||
<td className="py-1.5 text-label">{api.desc}</td>
|
||
<td className="py-1.5 text-muted-foreground">{api.sfr}</td>
|
||
<td className="py-1.5 text-center">
|
||
<Badge intent={getStatusIntent(api.status)} size="xs">{api.status}</Badge>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 하단: API 호출 예시 + 연계 서비스 */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{/* API 호출 예시 */}
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||
<Code className="w-4 h-4 text-cyan-400" />
|
||
API 호출 예시
|
||
</div>
|
||
<div className="space-y-3">
|
||
{/* 격자 조회 */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="text-[10px] text-muted-foreground">격자별 위험도 조회 (파라미터: 좌표 범위, 시간)</span>
|
||
<button type="button" aria-label="예시 URL 복사" onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button>
|
||
</div>
|
||
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-muted-foreground overflow-x-auto">
|
||
{`GET /api/v1/predictions/grid
|
||
?lat_min=36.0&lat_max=38.0
|
||
&lon_min=124.0&lon_max=126.0
|
||
&time=2026-04-03T09:00Z`}
|
||
</pre>
|
||
</div>
|
||
{/* 응답 예시 */}
|
||
<div>
|
||
<div className="text-[10px] text-muted-foreground mb-1">응답 (JSON)</div>
|
||
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-emerald-400/80 overflow-x-auto">
|
||
{`{
|
||
"model_version": "v2.3.1",
|
||
"predicted_at": "2026-04-03T09:00:00Z",
|
||
"grids": [
|
||
{
|
||
"grid_id": "G-37120-12463",
|
||
"center": { "lat": 37.120, "lon": 124.630 },
|
||
"risk_score": 87.5,
|
||
"illegal_prob": 0.92,
|
||
"dark_vessel_count": 3,
|
||
"top_threat": "EEZ_VIOLATION"
|
||
},
|
||
{
|
||
"grid_id": "G-36800-12422",
|
||
"center": { "lat": 36.800, "lon": 124.220 },
|
||
"risk_score": 45.2,
|
||
"illegal_prob": 0.38,
|
||
"dark_vessel_count": 0,
|
||
"top_threat": null
|
||
}
|
||
],
|
||
"total": 1842,
|
||
"page": 1,
|
||
"page_size": 100
|
||
}`}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 연계 서비스 매핑 */}
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||
<ExternalLink className="w-4 h-4 text-purple-400" />
|
||
후속 서비스 연계 매핑
|
||
</div>
|
||
<div className="space-y-2">
|
||
{[
|
||
{ sfr: 'SFR-06', name: '위험도 지도 서비스', apis: ['grid/heatmap', 'zone', 'time'], color: '#ef4444',
|
||
desc: '격자 히트맵 → 위험도 레이어 표출, 해역별 집계 → 관할해역 위험 현황, 시간별 추이 → 변화 애니메이션' },
|
||
{ sfr: 'SFR-09', name: '불법어선 탐지 서비스', apis: ['zone/{id}/vessels', 'vessel/{mmsi}', 'alarms'], color: '#f97316',
|
||
desc: '수역별 의심 선박 목록 → 타겟 리스트, 선박별 위험도 이력 → 상세 분석, 알람 → 실시간 경보' },
|
||
{ sfr: 'SFR-05', name: 'MDA 상황판', apis: ['grid', 'zone', 'status'], color: '#3b82f6',
|
||
desc: '격자+해역 위험도 → 종합 상황 오버레이, 엔진 상태 → 시스템 모니터링 패널' },
|
||
{ sfr: 'SFR-10', name: 'AI 순찰경로 최적화', apis: ['grid/heatmap', 'time/forecast'], color: '#10b981',
|
||
desc: '위험도 히트맵 → 순찰 우선순위 지역, 예측 → 사전 배치 경로 생성' },
|
||
{ sfr: 'SFR-11', name: '보고서·증거 관리', apis: ['alarms', 'vessel/{mmsi}'], color: '#8b5cf6',
|
||
desc: '알람 이력 → 자동 보고서 첨부, 선박 위험도 → 증거 패키지' },
|
||
].map((s) => (
|
||
<div key={s.sfr} className="px-3 py-2.5 rounded-lg bg-surface-overlay border border-border">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<Badge size="sm" className="font-bold" style={{ backgroundColor: s.color, borderColor: s.color }}>{s.sfr}</Badge>
|
||
<span className="text-[11px] font-bold text-heading">{s.name}</span>
|
||
</div>
|
||
<div className="text-[9px] text-hint mb-1.5">{s.desc}</div>
|
||
<div className="flex gap-1 flex-wrap">
|
||
{s.apis.map((a) => (
|
||
<span key={a} className="text-[8px] font-mono px-1.5 py-0.5 rounded bg-switch-background/50 text-cyan-400">{a}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* API 사용 통계 */}
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3">API 호출 통계 (금일)</div>
|
||
<div className="flex gap-3">
|
||
{[
|
||
{ label: '총 호출', value: '142,856', color: 'text-heading' },
|
||
{ label: 'grid 조회', value: '68,420', color: 'text-blue-400' },
|
||
{ label: 'zone 조회', value: '32,115', color: 'text-green-400' },
|
||
{ label: 'time 조회', value: '18,903', color: 'text-yellow-400' },
|
||
{ label: 'vessel 조회', value: '15,210', color: 'text-orange-400' },
|
||
{ label: 'alarms', value: '8,208', color: 'text-red-400' },
|
||
{ label: '평균 응답', value: '23ms', color: 'text-cyan-400' },
|
||
{ label: '오류율', value: '0.03%', color: 'text-green-400' },
|
||
].map((s) => (
|
||
<div key={s.label} className="flex-1 text-center px-3 py-2 rounded-lg bg-surface-overlay">
|
||
<div className={`text-sm font-bold ${s.color}`}>{s.value}</div>
|
||
<div className="text-[9px] text-hint">{s.label}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</PageContainer>
|
||
);
|
||
}
|