iran 백엔드 프록시 잔재 제거: - IranBackendClient dead class 삭제, AppProperties/application.yml iran-backend 블록 제거 - Frontend UI 라벨/주석/system-flow manifest deprecated 마킹 - CLAUDE.md 시스템 구성 다이어그램 최신화 백엔드 계층 분리: - AlertController/MasterDataController/AdminStatsController 에서 repository/JdbcTemplate 직접 주입 제거 - AlertService/MasterDataService/AdminStatsService 신규 계층 도입 + @Transactional(readOnly=true) - Proxy controller 의 @PostConstruct RestClient 생성 → RestClientConfig @Bean 으로 통합 감사 로그 보강: - EnforcementService createRecord/updateRecord/createPlan 에 @Auditable 추가 - VesselAnalysisGroupService.resolveParent 에 PARENT_RESOLVE 액션 기록 카탈로그 정합성: - performanceStatus 를 catalogRegistry 에 등록 (쇼케이스 자동 노출) - alertLevels 확장: isValidAlertLevel / isHighSeverity / getAlertLevelOrder - LiveMapView/DarkVesselDetection 시각 매핑(opacity/radius/tier score) 상수로 추출 - GearIdentification/vesselAnomaly 직접 분기를 타입 가드/헬퍼로 치환
1252 lines
54 KiB
TypeScript
1252 lines
54 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Search, Anchor, Ship, AlertTriangle, CheckCircle, XCircle,
|
|
ChevronRight, Info, Shield, Radar, Target, Waves,
|
|
ArrowRight, Zap, HelpCircle, Loader2, RefreshCw
|
|
} from 'lucide-react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import { getAlertLevelIntent, isValidAlertLevel } from '@shared/constants/alertLevels';
|
|
import { getZoneCodeLabel } from '@shared/constants/zoneCodes';
|
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
|
import { getGearDetections, type GearDetection } from '@/services/analysisApi';
|
|
|
|
// ─── 판별 기준 데이터 ─────────────────
|
|
|
|
type GearType = 'trawl' | 'gillnet' | 'purseSeine' | 'setNet' | 'trap' | 'unknown';
|
|
type Origin = 'china' | 'korea' | 'uncertain';
|
|
type Confidence = 'high' | 'medium' | 'low';
|
|
|
|
interface IdentificationResult {
|
|
origin: Origin;
|
|
confidence: Confidence;
|
|
gearType: GearType;
|
|
gearSubType: string;
|
|
gbCode: string;
|
|
koreaName: string;
|
|
reasons: string[];
|
|
warnings: string[];
|
|
actionRequired: string;
|
|
alertLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
|
}
|
|
|
|
// ─── 판별 입력 폼 상태 ────────────────
|
|
|
|
interface GearInput {
|
|
// 어구 물리적 특성
|
|
gearCategory: GearType;
|
|
meshSize: number | null; // mm
|
|
netLength: number | null; // m
|
|
netWidth: number | null; // m
|
|
hasChineseMarkings: boolean;
|
|
hasBuoys: boolean;
|
|
buoyCount: number | null;
|
|
hasFrame: boolean; // 프레임 구조 (정치망)
|
|
hasDrawstring: boolean; // 죔줄 (선망)
|
|
|
|
// AIS/선박 정보
|
|
aisActive: boolean;
|
|
aisGapHours: number | null;
|
|
mmsiPrefix: string;
|
|
permitCode: string; // C21, C22, C23, C25, C40 등
|
|
vesselTonnage: number | null;
|
|
vesselCount: number;
|
|
hasCompanionVessel: boolean; // 부속선 동반
|
|
|
|
// 행동 패턴
|
|
speedKnots: number | null;
|
|
trajectoryPattern: 'lawnmowing' | 'stationary' | 'circular' | 'drifting' | 'linear' | 'unknown';
|
|
operatingHours: number | null;
|
|
isNightOperation: boolean;
|
|
hasFishLight: boolean; // 집어등
|
|
|
|
// 위치/시기
|
|
seaArea: string; // 수역 I~IV
|
|
discoveryDate: string;
|
|
hasVesselNearby: boolean;
|
|
}
|
|
|
|
const DEFAULT_INPUT: GearInput = {
|
|
gearCategory: 'unknown',
|
|
meshSize: null,
|
|
netLength: null,
|
|
netWidth: null,
|
|
hasChineseMarkings: false,
|
|
hasBuoys: false,
|
|
buoyCount: null,
|
|
hasFrame: false,
|
|
hasDrawstring: false,
|
|
aisActive: true,
|
|
aisGapHours: null,
|
|
mmsiPrefix: '',
|
|
permitCode: '',
|
|
vesselTonnage: null,
|
|
vesselCount: 1,
|
|
hasCompanionVessel: false,
|
|
speedKnots: null,
|
|
trajectoryPattern: 'unknown',
|
|
operatingHours: null,
|
|
isNightOperation: false,
|
|
hasFishLight: false,
|
|
seaArea: '',
|
|
discoveryDate: '',
|
|
hasVesselNearby: true,
|
|
};
|
|
|
|
// ─── 판별 엔진 ────────────────────────
|
|
|
|
function identifyGear(input: GearInput): IdentificationResult {
|
|
const reasons: string[] = [];
|
|
const warnings: string[] = [];
|
|
let chinaScore = 0;
|
|
let koreaScore = 0;
|
|
let gearType: GearType = input.gearCategory;
|
|
let gearSubType = '';
|
|
let gbCode = '';
|
|
let koreaName = '';
|
|
let alertLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' = 'LOW';
|
|
|
|
// ── 1단계: 허가코드 기반 즉시 판별 ──
|
|
const permitPrefix = input.permitCode.toUpperCase();
|
|
if (permitPrefix.startsWith('C21')) {
|
|
chinaScore += 50;
|
|
gearType = 'trawl';
|
|
gearSubType = '2척식저인망(PT) 본선/부속선';
|
|
gbCode = 'TDS (쌍선저층트롤)';
|
|
koreaName = '쌍끌이기선저인망';
|
|
reasons.push('허가코드 C21-xxxxx → 중국 2척식저인망(PT) 확정');
|
|
} else if (permitPrefix.startsWith('C22')) {
|
|
chinaScore += 50;
|
|
gearType = 'trawl';
|
|
gearSubType = '1척식저인망(OT)';
|
|
gbCode = 'TDD (단선저층트롤)';
|
|
koreaName = '기선저인망';
|
|
reasons.push('허가코드 C22-xxxxx → 중국 1척식저인망(OT) 확정');
|
|
} else if (permitPrefix.startsWith('C23')) {
|
|
chinaScore += 50;
|
|
gearType = 'purseSeine';
|
|
gearSubType = '위망(PS) — 宁波海裕 선단';
|
|
gbCode = 'WDD (집어등단선선망)';
|
|
koreaName = '대형선망/근해선망';
|
|
reasons.push('허가코드 C23-xxxxx → 중국 위망(PS), 宁波海裕渔业 소속 확정');
|
|
warnings.push('위망 16척은 연중 전수역 허가 — 시기/장소 기반 자동 위반 판별 불가');
|
|
} else if (permitPrefix.startsWith('C25')) {
|
|
chinaScore += 50;
|
|
gearType = 'gillnet';
|
|
gearSubType = '유망(GN) — 유자망';
|
|
gbCode = 'CLD (단선유자망)';
|
|
koreaName = '유자망/연안유자망';
|
|
reasons.push('허가코드 C25-xxxxx → 중국 유망(GN) 확정');
|
|
warnings.push('유망은 AIS 차단 최다 업종 — 다크베셀 주의');
|
|
} else if (permitPrefix.startsWith('C40')) {
|
|
chinaScore += 50;
|
|
gearSubType = '운반선(FC)';
|
|
gbCode = '해당없음 (운반 전용)';
|
|
koreaName = '운반선';
|
|
reasons.push('허가코드 C40-xxxxx → 중국 운반선(FC) 확정, 할당량 0톤');
|
|
warnings.push('조업선 0.5NM 이내 접근 시 환적 의심 알람 발동');
|
|
}
|
|
|
|
// ── 2단계: 중국어 표기 확인 ──
|
|
if (input.hasChineseMarkings) {
|
|
chinaScore += 30;
|
|
reasons.push('어구/부표에 중국어 표기 확인 → 중국 어구 강력 추정');
|
|
}
|
|
|
|
// ── 3단계: 어구 유형별 물리적 특성 판별 ──
|
|
|
|
// ■ 트롤(저인망) 판별
|
|
if (gearType === 'trawl' || input.trajectoryPattern === 'lawnmowing') {
|
|
if (!gearType || gearType === 'unknown') gearType = 'trawl';
|
|
|
|
// 속도 패턴
|
|
if (input.speedKnots !== null && input.speedKnots >= 2 && input.speedKnots <= 5) {
|
|
reasons.push(`속도 ${input.speedKnots}kt → 트롤 조업 속도 범위(2~5kt) 일치`);
|
|
}
|
|
|
|
// 쌍선 운용 여부
|
|
if (input.hasCompanionVessel || input.vesselCount === 2) {
|
|
chinaScore += 15;
|
|
gearSubType = gearSubType || '2척식저인망(PT) 추정';
|
|
gbCode = gbCode || 'TDS';
|
|
reasons.push('2척 편대 운용 확인 → 중국 PT(쌍선저인망) 구조와 일치');
|
|
if (input.vesselTonnage !== null) {
|
|
if (input.vesselTonnage >= 68 && input.vesselTonnage <= 297) {
|
|
chinaScore += 10;
|
|
reasons.push(`선박 톤수 ${input.vesselTonnage}톤 → 중국 PT 평균 범위(68~297톤) 일치`);
|
|
}
|
|
}
|
|
} else if (input.vesselCount === 1 && input.vesselTonnage !== null && input.vesselTonnage >= 180) {
|
|
chinaScore += 8;
|
|
gearSubType = gearSubType || '1척식저인망(OT) 추정';
|
|
gbCode = gbCode || 'TDD';
|
|
reasons.push(`단선 운용 + ${input.vesselTonnage}톤 → 중국 OT(1척식저인망) 추정`);
|
|
}
|
|
|
|
// 망목 규격
|
|
if (input.meshSize !== null) {
|
|
if (input.meshSize < 54) {
|
|
chinaScore += 10;
|
|
warnings.push(`망목 ${input.meshSize}mm — 한국 기준 54mm 미만 → 금지 어구 사용 위반`);
|
|
alertLevel = 'HIGH';
|
|
} else {
|
|
reasons.push(`망목 ${input.meshSize}mm — 한국 기준(54mm) 충족`);
|
|
}
|
|
}
|
|
|
|
koreaName = koreaName || (input.vesselCount >= 2 ? '쌍끌이기선저인망' : '기선저인망');
|
|
}
|
|
|
|
// ■ 자망(유자망) 판별
|
|
if (gearType === 'gillnet' || input.trajectoryPattern === 'stationary' || input.trajectoryPattern === 'drifting') {
|
|
if (!gearType || gearType === 'unknown') gearType = 'gillnet';
|
|
|
|
// AIS 소실 — 중국 자망의 핵심 지표
|
|
if (!input.aisActive || (input.aisGapHours !== null && input.aisGapHours >= 6)) {
|
|
chinaScore += 25;
|
|
reasons.push(`AIS ${input.aisGapHours || '?'}시간 소실 → 중국 유망(GN) 다크베셀 전술 패턴`);
|
|
warnings.push('자망 탐지 핵심: AIS 공백 구간을 SAR 영상과 교차 검증 필수');
|
|
alertLevel = alertLevel === 'LOW' ? 'HIGH' : alertLevel;
|
|
} else if (input.aisActive) {
|
|
koreaScore += 10;
|
|
reasons.push('AIS 지속 송출 → 한국 유자망어선은 V-PASS + AIS 이중 추적 의무');
|
|
}
|
|
|
|
// 그물 규모
|
|
if (input.netLength !== null && input.netLength > 500) {
|
|
chinaScore += 15;
|
|
reasons.push(`그물 길이 ${input.netLength}m → 중국식 광폭·장형 대형 자망 특성`);
|
|
} else if (input.netLength !== null && input.netLength <= 500) {
|
|
koreaScore += 5;
|
|
reasons.push(`그물 길이 ${input.netLength}m → 한국 규격 범위 내`);
|
|
}
|
|
|
|
// 망목
|
|
if (input.meshSize !== null) {
|
|
if (input.meshSize >= 50 && input.meshSize <= 100) {
|
|
reasons.push(`망목 ${input.meshSize}mm — 한국 유자망 기준(50~100mm) 범위 내`);
|
|
} else if (input.meshSize < 50) {
|
|
chinaScore += 10;
|
|
warnings.push(`망목 ${input.meshSize}mm — 한국 기준 미달, 불법 어구 의심`);
|
|
}
|
|
}
|
|
|
|
// 정지 패턴
|
|
if (input.speedKnots !== null && input.speedKnots < 2) {
|
|
reasons.push(`속도 ${input.speedKnots}kt → 자망 조업 속도(0~2kt) 범위 일치`);
|
|
}
|
|
|
|
// 부표 다수 발견
|
|
if (input.hasBuoys && input.buoyCount !== null && input.buoyCount >= 10) {
|
|
reasons.push(`부표 ${input.buoyCount}개 발견 → SAR 고해상도에서 자망 설치 식별 지표`);
|
|
}
|
|
|
|
gbCode = gbCode || 'CLD/CLS';
|
|
koreaName = koreaName || '유자망어업';
|
|
gearSubType = gearSubType || '유자망(Gillnet)';
|
|
}
|
|
|
|
// ■ 선망(위망) 판별
|
|
if (gearType === 'purseSeine' || input.trajectoryPattern === 'circular') {
|
|
if (!gearType || gearType === 'unknown') gearType = 'purseSeine';
|
|
|
|
// 원형 궤적 + 고→저속 급변
|
|
if (input.trajectoryPattern === 'circular') {
|
|
reasons.push('원형/타원형 궤적 탐지 → 선망(Purse Seine) 패턴 확정 (신뢰도 94~97%)');
|
|
}
|
|
|
|
if (input.speedKnots !== null && input.speedKnots >= 8) {
|
|
reasons.push(`고속 ${input.speedKnots}kt 원형 이동 → 선망 포획 단계(8~10kt) 일치`);
|
|
}
|
|
|
|
// 선단 구조 — 중국 위망 핵심
|
|
if (input.vesselCount >= 3) {
|
|
chinaScore += 25;
|
|
reasons.push(`${input.vesselCount}척 클러스터 → 중국 위망 선단(모선+운반선+조명선) 구조`);
|
|
gearSubType = gearSubType || '위망 선단(Fleet)';
|
|
} else if (input.vesselCount <= 2) {
|
|
koreaScore += 10;
|
|
reasons.push('소규모 선박 구성 → 한국 선망어업 가능성');
|
|
}
|
|
|
|
// 야간 집어등
|
|
if (input.hasFishLight || input.isNightOperation) {
|
|
chinaScore += 20;
|
|
reasons.push('야간 집어등(灯光) 사용 → 중국 WDD(집어등선망) 코드, 위반 건수 1위');
|
|
gbCode = gbCode || 'WDD';
|
|
warnings.push('야간 EO 위성에서 최우선 탐지 대상');
|
|
}
|
|
|
|
// 초대형 선박
|
|
if (input.vesselTonnage !== null && input.vesselTonnage >= 400) {
|
|
chinaScore += 15;
|
|
reasons.push(`${input.vesselTonnage}톤 → 宁波海裕 초대형 모선급(宁渔22·23, 490~541톤) 의심`);
|
|
warnings.push('어획 할당량 0톤 등록 선박 — 해상 냉동·집하 기지 역할 확인 필요');
|
|
}
|
|
|
|
gbCode = gbCode || 'WDD/WDW';
|
|
koreaName = koreaName || '선망어업';
|
|
gearSubType = gearSubType || '선망(Purse Seine)';
|
|
|
|
if (input.hasDrawstring) {
|
|
reasons.push('죔줄(Purseline) 구조 확인 → 선망 어구 확정');
|
|
}
|
|
}
|
|
|
|
// ■ 정치망 판별
|
|
if (gearType === 'setNet' || input.hasFrame) {
|
|
gearType = 'setNet';
|
|
chinaScore += 30; // 한중 EEZ 내 정치망은 중국 미허가
|
|
gbCode = 'ZD/ZS (張網)';
|
|
koreaName = '연안정치망/대형정치망';
|
|
gearSubType = '정치망(張網) — 단묘/복묘장망';
|
|
alertLevel = 'CRITICAL';
|
|
reasons.push('정치망(張網) 구조 확인 → 한중어업협정 특정어업수역 내 중국 미허가 어구');
|
|
warnings.push('정치망은 EEZ 내 중국어선 사용 절대 금지 — 발견 즉시 수거 및 CRITICAL 처리');
|
|
|
|
if (!input.hasVesselNearby) {
|
|
reasons.push('선박 없이 어구만 발견 → 중국어선 투기 정치망 전형적 패턴(이어도·서남해 다수)');
|
|
}
|
|
}
|
|
|
|
// ■ 통발 판별
|
|
if (gearType === 'trap') {
|
|
gbCode = 'L (笼壶)';
|
|
koreaName = '근해통발/연안통발';
|
|
gearSubType = '통발(笼壶)';
|
|
reasons.push('통발 형태 어구 — 한중어업협정 비포함 어구, 적발 사례 드묾');
|
|
}
|
|
|
|
// ── 4단계: 위치/시기 기반 보정 ──
|
|
|
|
if (input.seaArea) {
|
|
const area = input.seaArea.toUpperCase();
|
|
// 수역 IV에 PT/OT 선박이면 수역 이탈
|
|
if ((area.includes('IV') || area.includes('4') || area.includes('서해')) &&
|
|
(permitPrefix.startsWith('C21') || permitPrefix.startsWith('C22'))) {
|
|
warnings.push('수역Ⅳ(서해)에서 저인망(PT/OT) 탐지 → 허가 수역 이탈 위반');
|
|
alertLevel = 'CRITICAL';
|
|
}
|
|
// 수역 I에 GN 선박이면 수역 이탈
|
|
if ((area.includes('I') || area.includes('1') || area.includes('동해')) && area.indexOf('V') === -1 &&
|
|
permitPrefix.startsWith('C25')) {
|
|
warnings.push('수역Ⅰ(동해)에서 유망(GN) 탐지 → 허가 수역 이탈 위반');
|
|
alertLevel = 'CRITICAL';
|
|
}
|
|
}
|
|
|
|
// 날짜 기반 휴어기 판별
|
|
if (input.discoveryDate) {
|
|
const date = new Date(input.discoveryDate);
|
|
const month = date.getMonth() + 1;
|
|
const day = date.getDate();
|
|
|
|
// PT/OT 휴어기: 4/16 ~ 10/15
|
|
if (permitPrefix.startsWith('C21') || permitPrefix.startsWith('C22')) {
|
|
if ((month === 4 && day >= 16) || (month > 4 && month < 10) || (month === 10 && day <= 15)) {
|
|
warnings.push(`${month}월 ${day}일 — 저인망(PT/OT) 휴어기(4/16~10/15) 위반`);
|
|
alertLevel = 'CRITICAL';
|
|
}
|
|
}
|
|
// GN 휴어기: 6/2 ~ 8/31
|
|
if (permitPrefix.startsWith('C25')) {
|
|
if ((month === 6 && day >= 2) || (month === 7) || (month === 8)) {
|
|
warnings.push(`${month}월 ${day}일 — 유망(GN) 휴어기(6/2~8/31) 위반`);
|
|
alertLevel = 'CRITICAL';
|
|
}
|
|
}
|
|
|
|
// 7~8월: PS 16척 외 모든 중국어선 비허가
|
|
if (month === 7 || month === 8) {
|
|
if (!permitPrefix.startsWith('C23') && !permitPrefix.startsWith('C40') && chinaScore > 20) {
|
|
warnings.push('7~8월 최대 감시 기간 — 위망(PS) 16척 외 전 중국어선 비허가');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 5단계: 최종 판정 ──
|
|
|
|
let origin: Origin;
|
|
let confidence: Confidence;
|
|
|
|
if (chinaScore >= 50) {
|
|
origin = 'china';
|
|
confidence = chinaScore >= 80 ? 'high' : chinaScore >= 60 ? 'medium' : 'low';
|
|
} else if (koreaScore >= 30 && chinaScore < 20) {
|
|
origin = 'korea';
|
|
confidence = koreaScore >= 50 ? 'high' : 'medium';
|
|
} else if (chinaScore > koreaScore) {
|
|
origin = 'china';
|
|
confidence = 'low';
|
|
} else if (koreaScore > chinaScore) {
|
|
origin = 'korea';
|
|
confidence = 'low';
|
|
} else {
|
|
origin = 'uncertain';
|
|
confidence = 'low';
|
|
reasons.push('판별 근거 부족 — 추가 현장 확인 필요 (SAR/RF 교차 검증 권고)');
|
|
}
|
|
|
|
// 알람 등급 보정
|
|
if (origin === 'china' && alertLevel === 'LOW') {
|
|
alertLevel = gearType === 'setNet' ? 'CRITICAL' : 'MEDIUM';
|
|
}
|
|
|
|
const actionMap: Record<string, string> = {
|
|
'china-CRITICAL': '즉시 나포 대상 — 정선 명령 후 승선 검사 실시',
|
|
'china-HIGH': '우선 추적 — SAR/RF 교차 검증 후 임검 준비',
|
|
'china-MEDIUM': '지속 감시 — 허가증 확인 대상 선정',
|
|
'china-LOW': '일반 감시 — 추가 정보 수집 후 재판별',
|
|
'korea-CRITICAL': '한국 어선이나 금지 어구 사용 — 관할 해경서 통보',
|
|
'korea-HIGH': '한국 어선 확인 — 허가 조건 이행 점검',
|
|
'korea-MEDIUM': '한국 어선 추정 — 일반 관리',
|
|
'korea-LOW': '추가 확인 필요',
|
|
'uncertain-LOW': '판별 불가 — 현장 승선 검사 또는 SAR·RF 교차 확인 필수',
|
|
};
|
|
|
|
return {
|
|
origin,
|
|
confidence,
|
|
gearType: gearType || 'unknown',
|
|
gearSubType: gearSubType || '미분류',
|
|
gbCode: gbCode || '미확인',
|
|
koreaName: koreaName || '미확인',
|
|
reasons,
|
|
warnings,
|
|
actionRequired: actionMap[`${origin}-${alertLevel}`] || actionMap[`${origin}-LOW`] || '추가 확인 필요',
|
|
alertLevel,
|
|
};
|
|
}
|
|
|
|
// ─── 서브 컴포넌트 ────────────────────
|
|
|
|
function SectionHeader({ icon: Icon, title, color }: { icon: React.ElementType; title: string; color: string }) {
|
|
return (
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Icon className="w-4 h-4" style={{ color }} />
|
|
<h3 className="text-sm font-bold text-foreground">{title}</h3>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FormField({ label, children, hint }: { label: string; children: React.ReactNode; hint?: string }) {
|
|
return (
|
|
<div className="space-y-1">
|
|
<label className="text-[11px] text-muted-foreground font-medium">{label}</label>
|
|
{children}
|
|
{hint && <p className="text-[9px] text-hint">{hint}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InputField({ value, onChange, placeholder, type = 'text', className = '', label }: {
|
|
value: string | number | null;
|
|
onChange: (v: string) => void;
|
|
placeholder: string;
|
|
type?: string;
|
|
className?: string;
|
|
label?: string;
|
|
}) {
|
|
return (
|
|
<input
|
|
aria-label={label ?? placeholder}
|
|
type={type}
|
|
value={value ?? ''}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className={`w-full bg-surface-overlay border border-slate-700/50 rounded-md px-2.5 py-1.5 text-xs text-heading placeholder:text-hint focus:border-blue-500/50 focus:outline-none ${className}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label: string }) {
|
|
return (
|
|
<label className="flex items-center gap-2 cursor-pointer group">
|
|
<div
|
|
className={`w-8 h-4 rounded-full transition-colors relative ${checked ? 'bg-blue-600' : 'bg-switch-background'}`}
|
|
onClick={() => onChange(!checked)}
|
|
>
|
|
<div className={`w-3 h-3 rounded-full bg-white absolute top-0.5 transition-transform ${checked ? 'translate-x-4.5 left-0.5' : 'left-0.5'}`}
|
|
style={{ transform: checked ? 'translateX(16px)' : 'translateX(0)' }}
|
|
/>
|
|
</div>
|
|
<span className="text-[11px] text-muted-foreground group-hover:text-label">{label}</span>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function SelectField({ label, value, onChange, options }: {
|
|
label: string;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
options: { value: string; label: string }[];
|
|
}) {
|
|
return (
|
|
<select
|
|
aria-label={label}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="w-full bg-surface-overlay border border-slate-700/50 rounded-md px-2.5 py-1.5 text-xs text-heading focus:border-blue-500/50 focus:outline-none"
|
|
>
|
|
{options.map((o) => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
function ResultBadge({ origin, confidence }: { origin: Origin; confidence: Confidence }) {
|
|
const intents: Record<Origin, import('@lib/theme/variants').BadgeIntent> = {
|
|
china: 'critical',
|
|
korea: 'info',
|
|
uncertain: 'warning',
|
|
};
|
|
const labels: Record<Origin, string> = { china: '중국어선 어구', korea: '한국어선 어구', uncertain: '판별 불가' };
|
|
const confLabels: Record<Confidence, string> = { high: '높음', medium: '보통', low: '낮음' };
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<Badge intent={intents[origin]} size="md">{labels[origin]}</Badge>
|
|
<span className="text-[10px] text-hint">신뢰도: {confLabels[confidence]}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AlertBadge({ level }: { level: string }) {
|
|
const intents: Record<string, import('@lib/theme/variants').BadgeIntent> = {
|
|
CRITICAL: 'critical',
|
|
HIGH: 'high',
|
|
MEDIUM: 'warning',
|
|
LOW: 'info',
|
|
};
|
|
return <Badge intent={intents[level] ?? 'muted'} size="xs">{level}</Badge>;
|
|
}
|
|
|
|
// ─── 어구 비교 레퍼런스 테이블 ──────────
|
|
|
|
function GearComparisonTable() {
|
|
const data = [
|
|
{
|
|
type: '트롤 (저인망)',
|
|
gbCode: 'T__ (TDD/TDS)',
|
|
chinaFeatures: ['2척 편대 필수(PT)', '68~297톤', '망목 GB/T 기준', '수역 II·III 한정'],
|
|
koreaFeatures: ['단독/2척 자율', '규모 다양', '망목 54mm+', '국내 허가수역'],
|
|
aiPattern: 'Lawn-mowing (지그재그 반복)',
|
|
speed: '2~5 kt',
|
|
confidence: '89~92%',
|
|
risk: 'HIGH',
|
|
},
|
|
{
|
|
type: '자망 (유자망)',
|
|
gbCode: 'C__ (CLD/CLS)',
|
|
chinaFeatures: ['AIS OFF 빈번', '광폭·장형 대형망', '은밀 설치', '수역 II·III·IV'],
|
|
koreaFeatures: ['V-PASS+AIS 의무', '50~100mm 망목', '기계화 투·양망', '국내 수역'],
|
|
aiPattern: '정지·재방문 (투→대기→양)',
|
|
speed: '0~2 kt',
|
|
confidence: '74~80%',
|
|
risk: 'HIGH',
|
|
},
|
|
{
|
|
type: '선망 (위망)',
|
|
gbCode: 'W__ (WDD/WDW)',
|
|
chinaFeatures: ['3척+ 선단 체계', '야간 집어등 상시', '113~541톤', '전 수역 연중'],
|
|
koreaFeatures: ['소형~중형', '집어등 일부 허가', '소수 Fleet', '국내 수역'],
|
|
aiPattern: '원형 포획 (Circle Pattern)',
|
|
speed: '8~10→3kt 급변',
|
|
confidence: '94~97%',
|
|
risk: 'CRITICAL',
|
|
},
|
|
{
|
|
type: '정치망',
|
|
gbCode: 'Z__ (ZD/ZS)',
|
|
chinaFeatures: ['EEZ 내 미허가', '선박 없이 어구만 투기', '이어도·서남해 발견'],
|
|
koreaFeatures: ['연안정치망 합법', '위치 등록 의무', '어업권 면허'],
|
|
aiPattern: '고정 설치 (이동 없음)',
|
|
speed: '0 kt',
|
|
confidence: 'SAR/드론 필수',
|
|
risk: 'CRITICAL',
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="px-4 pt-3 pb-0">
|
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
|
<Info className="w-3.5 h-3.5 text-blue-400" />
|
|
한·중 어구 비교 레퍼런스 (GB/T 5147-2003 기반)
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="px-4 pb-4 pt-3">
|
|
<div className="space-y-3">
|
|
{data.map((row) => (
|
|
<div key={row.type} className="bg-surface-overlay rounded-lg p-3 border border-slate-700/30">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-bold text-heading">{row.type}</span>
|
|
<span className="text-[9px] text-hint bg-switch-background/50 px-1.5 py-0.5 rounded">{row.gbCode}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[9px] text-muted-foreground">AI 신뢰도: {row.confidence}</span>
|
|
<AlertBadge level={row.risk} />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<div className="text-[9px] text-red-400 font-medium mb-1">중국어선 특징</div>
|
|
{row.chinaFeatures.map((f, i) => (
|
|
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
|
|
<span className="text-red-500 mt-0.5 shrink-0">-</span>{f}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div>
|
|
<div className="text-[9px] text-blue-400 font-medium mb-1">한국어선 특징</div>
|
|
{row.koreaFeatures.map((f, i) => (
|
|
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
|
|
<span className="text-blue-500 mt-0.5 shrink-0">-</span>{f}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 flex items-center gap-4 text-[9px] text-hint">
|
|
<span>궤적: {row.aiPattern}</span>
|
|
<span>속도: {row.speed}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─── 메인 페이지 ──────────────────────
|
|
|
|
// gearCode → gearCategory 매핑 (자동탐지 → 입력 폼 프리필용)
|
|
const GEAR_CODE_CATEGORY: Record<string, GearType> = {
|
|
C21: 'trawl', C22: 'trawl', PT: 'trawl', OT: 'trawl', TRAWL: 'trawl',
|
|
C23: 'purseSeine', PS: 'purseSeine', PURSE: 'purseSeine',
|
|
C25: 'gillnet', GN: 'gillnet', GNS: 'gillnet', GND: 'gillnet', GILLNET: 'gillnet',
|
|
C40: 'unknown', FC: 'unknown',
|
|
};
|
|
|
|
const ZONE_CODE_SEA_AREA: Record<string, string> = {
|
|
ZONE_I: 'I', ZONE_II: 'II', ZONE_III: 'III', ZONE_IV: 'IV',
|
|
TERRITORIAL_SEA: '영해', CONTIGUOUS_ZONE: '접속수역', EEZ_OR_BEYOND: 'EEZ 외',
|
|
};
|
|
|
|
export function GearIdentification() {
|
|
const { t } = useTranslation('detection');
|
|
const [input, setInput] = useState<GearInput>(DEFAULT_INPUT);
|
|
const [result, setResult] = useState<IdentificationResult | null>(null);
|
|
const [showReference, setShowReference] = useState(false);
|
|
const [autoSelected, setAutoSelected] = useState<GearDetection | null>(null);
|
|
|
|
const update = <K extends keyof GearInput>(key: K, value: GearInput[K]) => {
|
|
setInput((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const runIdentification = () => {
|
|
setResult(identifyGear(input));
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setInput(DEFAULT_INPUT);
|
|
setResult(null);
|
|
setAutoSelected(null);
|
|
};
|
|
|
|
// 자동탐지 row 선택 → 입력 폼 프리필 + 결과 패널에 근거 프리셋
|
|
const applyAutoDetection = (v: GearDetection) => {
|
|
const code = (v.gearCode || '').toUpperCase();
|
|
const category = GEAR_CODE_CATEGORY[code] ?? 'unknown';
|
|
const seaArea = v.zoneCode ? ZONE_CODE_SEA_AREA[v.zoneCode] ?? '' : '';
|
|
|
|
setInput({
|
|
...DEFAULT_INPUT,
|
|
gearCategory: category,
|
|
permitCode: code,
|
|
mmsiPrefix: v.mmsi.slice(0, 3),
|
|
seaArea,
|
|
discoveryDate: v.analyzedAt.slice(0, 10),
|
|
});
|
|
setAutoSelected(v);
|
|
|
|
// 자동탐지 근거를 결과 패널에 프리셋
|
|
const reasons: string[] = [];
|
|
const warnings: string[] = [];
|
|
reasons.push(`MMSI ${v.mmsi} · ${v.vesselType ?? 'UNKNOWN'} · prediction 자동탐지`);
|
|
reasons.push(`어구 코드: ${code} · 판정: ${GEAR_JUDGMENT_LABEL[v.gearJudgment] ?? v.gearJudgment}`);
|
|
if (v.permitStatus) {
|
|
reasons.push(`허가 상태: ${PERMIT_STATUS_LABEL[v.permitStatus] ?? v.permitStatus}`);
|
|
}
|
|
(v.violationCategories ?? []).forEach((cat) => warnings.push(`위반 카테고리: ${cat}`));
|
|
if (v.gearJudgment === 'CLOSED_SEASON_FISHING') warnings.push('금어기 조업 의심 — 허가기간 외 조업');
|
|
if (v.gearJudgment === 'UNREGISTERED_GEAR') warnings.push('미등록 어구 — fleet_vessels 미매칭');
|
|
if (v.gearJudgment === 'GEAR_MISMATCH') warnings.push('허가 어구와 실제 탐지 어구 불일치');
|
|
if (v.gearJudgment === 'MULTIPLE_VIOLATION') warnings.push('복합 위반 — 두 개 이상 항목 동시 탐지');
|
|
|
|
const alertLevel = isValidAlertLevel(v.riskLevel) ? v.riskLevel : 'LOW';
|
|
|
|
setResult({
|
|
origin: 'china',
|
|
confidence: v.riskScore && v.riskScore >= 70 ? 'high' : v.riskScore && v.riskScore >= 40 ? 'medium' : 'low',
|
|
gearType: category,
|
|
gearSubType: code,
|
|
gbCode: '',
|
|
koreaName: '',
|
|
reasons,
|
|
warnings,
|
|
actionRequired: alertLevel === 'CRITICAL' || alertLevel === 'HIGH'
|
|
? '현장 확인 및 보강 정보 입력 후 최종 판별 실행'
|
|
: '추가 정보 입력 후 판별 실행',
|
|
alertLevel,
|
|
});
|
|
|
|
// 입력 폼 영역으로 스크롤
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
|
<Search className="w-5 h-5 text-cyan-500" />
|
|
{t('gearId.title')}
|
|
</h2>
|
|
<p className="text-[10px] text-hint mt-0.5">
|
|
{t('gearId.desc')}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button type="button"
|
|
onClick={() => setShowReference(!showReference)}
|
|
className="px-3 py-1.5 bg-secondary border border-slate-700/50 rounded-md text-[11px] text-label hover:bg-switch-background transition-colors flex items-center gap-1.5"
|
|
>
|
|
<Info className="w-3 h-3" />
|
|
{showReference ? '레퍼런스 닫기' : '비교 레퍼런스'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 레퍼런스 테이블 (토글) */}
|
|
{showReference && <GearComparisonTable />}
|
|
|
|
{/* 자동탐지 선택 힌트 */}
|
|
{autoSelected && (
|
|
<div className="flex items-center justify-between gap-3 px-4 py-2.5 rounded-lg border border-cyan-500/30 bg-cyan-500/5 text-[11px]">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Badge intent="info" size="sm">자동탐지 연계</Badge>
|
|
<span className="text-hint">MMSI</span>
|
|
<span className="font-mono text-cyan-400">{autoSelected.mmsi}</span>
|
|
<span className="text-hint">·</span>
|
|
<span className="text-hint">어구</span>
|
|
<span className="font-mono text-label">{autoSelected.gearCode}</span>
|
|
<span className="text-hint">·</span>
|
|
<Badge intent={GEAR_JUDGMENT_INTENT[autoSelected.gearJudgment] ?? 'muted'} size="sm">
|
|
{GEAR_JUDGMENT_LABEL[autoSelected.gearJudgment] ?? autoSelected.gearJudgment}
|
|
</Badge>
|
|
<span className="text-hint ml-2">하단 자동탐지 목록에서 클릭한 선박 정보가 아래 폼에 프리필되었습니다. 추가 정보 입력 후 판별 실행하세요.</span>
|
|
</div>
|
|
<button type="button"
|
|
onClick={() => { setAutoSelected(null); setInput(DEFAULT_INPUT); setResult(null); }}
|
|
className="text-[10px] text-hint hover:text-heading shrink-0"
|
|
>
|
|
해제
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-12 gap-4">
|
|
{/* ── 좌측: 입력 폼 ── */}
|
|
<div className="col-span-5 space-y-3">
|
|
|
|
{/* 허가코드 / 선박 정보 */}
|
|
<Card>
|
|
<CardContent className="p-4 space-y-3">
|
|
<SectionHeader icon={Ship} title="선박/허가 정보" color="#06b6d4" />
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<FormField label="허가코드" hint="C21·C22·C23·C25·C40">
|
|
<InputField value={input.permitCode} onChange={(v) => update('permitCode', v)} placeholder="예: C21-13558" />
|
|
</FormField>
|
|
<FormField label="MMSI 앞자리">
|
|
<InputField value={input.mmsiPrefix} onChange={(v) => update('mmsiPrefix', v)} placeholder="예: 412" />
|
|
</FormField>
|
|
<FormField label="선박 톤수 (톤)">
|
|
<InputField value={input.vesselTonnage} onChange={(v) => update('vesselTonnage', v ? Number(v) : null)} placeholder="예: 127" type="number" />
|
|
</FormField>
|
|
<FormField label="선박 수 (척)">
|
|
<InputField value={input.vesselCount} onChange={(v) => update('vesselCount', Number(v) || 1)} placeholder="1" type="number" />
|
|
</FormField>
|
|
</div>
|
|
<Toggle checked={input.hasCompanionVessel} onChange={(v) => update('hasCompanionVessel', v)} label="부속선 동반 (PT 쌍선)" />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 어구 물리적 특성 */}
|
|
<Card>
|
|
<CardContent className="p-4 space-y-3">
|
|
<SectionHeader icon={Anchor} title="어구 물리적 특성" color="#a855f7" />
|
|
<FormField label="어구 유형 (추정)">
|
|
<SelectField label="어구 유형 (추정)" value={input.gearCategory} onChange={(v) => update('gearCategory', v as GearType)} options={[
|
|
{ value: 'unknown', label: '미확인 / 모름' },
|
|
{ value: 'trawl', label: '트롤 (저인망) — 끌고 다니는 어망' },
|
|
{ value: 'gillnet', label: '자망 (유자망) — 세워놓는 어망' },
|
|
{ value: 'purseSeine', label: '선망 (위망) — 둘러싸는 어망' },
|
|
{ value: 'setNet', label: '정치망 — 고정 설치 어망' },
|
|
{ value: 'trap', label: '통발 — 함정형 어구' },
|
|
]} />
|
|
</FormField>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<FormField label="망목 (mm)">
|
|
<InputField value={input.meshSize} onChange={(v) => update('meshSize', v ? Number(v) : null)} placeholder="mm" type="number" />
|
|
</FormField>
|
|
<FormField label="그물 길이 (m)">
|
|
<InputField value={input.netLength} onChange={(v) => update('netLength', v ? Number(v) : null)} placeholder="m" type="number" />
|
|
</FormField>
|
|
<FormField label="그물 폭 (m)">
|
|
<InputField value={input.netWidth} onChange={(v) => update('netWidth', v ? Number(v) : null)} placeholder="m" type="number" />
|
|
</FormField>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Toggle checked={input.hasChineseMarkings} onChange={(v) => update('hasChineseMarkings', v)} label="중국어 표기 확인 (부표/어구)" />
|
|
<Toggle checked={input.hasBuoys} onChange={(v) => update('hasBuoys', v)} label="부표(浮標) 발견" />
|
|
{input.hasBuoys && (
|
|
<FormField label="부표 수량">
|
|
<InputField value={input.buoyCount} onChange={(v) => update('buoyCount', v ? Number(v) : null)} placeholder="개수" type="number" />
|
|
</FormField>
|
|
)}
|
|
<Toggle checked={input.hasFrame} onChange={(v) => update('hasFrame', v)} label="프레임/고정 구조 (정치망)" />
|
|
<Toggle checked={input.hasDrawstring} onChange={(v) => update('hasDrawstring', v)} label="죔줄(Purseline) 구조 (선망)" />
|
|
<Toggle checked={input.hasVesselNearby} onChange={(v) => update('hasVesselNearby', v)} label="인근 선박 존재" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* AIS / 행동 패턴 */}
|
|
<Card>
|
|
<CardContent className="p-4 space-y-3">
|
|
<SectionHeader icon={Radar} title="AIS / 행동 패턴" color="#f97316" />
|
|
<Toggle checked={input.aisActive} onChange={(v) => update('aisActive', v)} label="AIS 송출 중" />
|
|
{!input.aisActive && (
|
|
<FormField label="AIS 소실 시간 (시간)">
|
|
<InputField value={input.aisGapHours} onChange={(v) => update('aisGapHours', v ? Number(v) : null)} placeholder="시간" type="number" />
|
|
</FormField>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<FormField label="속도 (knots)">
|
|
<InputField value={input.speedKnots} onChange={(v) => update('speedKnots', v ? Number(v) : null)} placeholder="kt" type="number" />
|
|
</FormField>
|
|
<FormField label="조업 지속시간 (시간)">
|
|
<InputField value={input.operatingHours} onChange={(v) => update('operatingHours', v ? Number(v) : null)} placeholder="h" type="number" />
|
|
</FormField>
|
|
</div>
|
|
<FormField label="궤적 패턴">
|
|
<SelectField label="궤적 패턴" value={input.trajectoryPattern} onChange={(v) => update('trajectoryPattern', v as GearInput['trajectoryPattern'])} options={[
|
|
{ value: 'unknown', label: '미확인' },
|
|
{ value: 'lawnmowing', label: '지그재그 반복 (Lawn-mowing) → 트롤' },
|
|
{ value: 'stationary', label: '정지/극저속 (Stationary) → 자망' },
|
|
{ value: 'circular', label: '원형/타원형 (Circle) → 선망' },
|
|
{ value: 'drifting', label: '표류 (Drifting) → 유자망' },
|
|
{ value: 'linear', label: '직선 이동 (Linear) → 이동/통과' },
|
|
]} />
|
|
</FormField>
|
|
<div className="space-y-2">
|
|
<Toggle checked={input.isNightOperation} onChange={(v) => update('isNightOperation', v)} label="야간 조업" />
|
|
<Toggle checked={input.hasFishLight} onChange={(v) => update('hasFishLight', v)} label="집어등(灯光) 사용" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 위치/시기 */}
|
|
<Card>
|
|
<CardContent className="p-4 space-y-3">
|
|
<SectionHeader icon={Target} title="발견 위치 / 시기" color="#10b981" />
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<FormField label="발견 수역" hint="수역 I~IV 또는 해역명">
|
|
<InputField value={input.seaArea} onChange={(v) => update('seaArea', v)} placeholder="예: 수역II, 서해 NLL" />
|
|
</FormField>
|
|
<FormField label="발견 일자">
|
|
<InputField value={input.discoveryDate} onChange={(v) => update('discoveryDate', v)} placeholder="YYYY-MM-DD" type="date" />
|
|
</FormField>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 판별 버튼 */}
|
|
<div className="flex gap-2">
|
|
<button type="button"
|
|
onClick={runIdentification}
|
|
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
<Zap className="w-4 h-4" />
|
|
어구 국적 판별 실행
|
|
</button>
|
|
<button type="button"
|
|
onClick={resetForm}
|
|
className="px-4 py-2.5 bg-secondary hover:bg-switch-background text-label text-sm rounded-lg transition-colors border border-slate-700/50"
|
|
>
|
|
초기화
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 우측: 결과 패널 ── */}
|
|
<div className="col-span-7 space-y-3">
|
|
{!result ? (
|
|
<Card className="bg-surface-raised border-border h-full flex items-center justify-center">
|
|
<CardContent className="text-center py-20">
|
|
<HelpCircle className="w-12 h-12 text-hint mx-auto mb-3" />
|
|
<p className="text-sm text-hint mb-1">좌측 입력 양식을 작성 후</p>
|
|
<p className="text-sm text-hint">"어구 국적 판별 실행" 버튼을 누르세요</p>
|
|
<p className="text-[10px] text-hint mt-3">
|
|
허가코드(C21~C40)만 입력해도 즉시 판별 가능합니다
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<>
|
|
{/* 판별 결과 헤더 */}
|
|
<Card className={`border ${
|
|
result.origin === 'china' ? 'bg-red-950/20 border-red-500/30'
|
|
: result.origin === 'korea' ? 'bg-blue-950/20 border-blue-500/30'
|
|
: 'bg-yellow-950/20 border-yellow-500/30'
|
|
}`}>
|
|
<CardContent className="p-5">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div>
|
|
<div className="text-[10px] text-hint mb-1">판별 결과</div>
|
|
<ResultBadge origin={result.origin} confidence={result.confidence} />
|
|
</div>
|
|
<AlertBadge level={result.alertLevel} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
|
<div>
|
|
<div className="text-[9px] text-hint">어구 유형</div>
|
|
<div className="text-xs text-heading font-bold">{result.gearSubType}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-[9px] text-hint">GB/T 코드</div>
|
|
<div className="text-xs text-heading font-bold">{result.gbCode}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-[9px] text-hint">한국 대응 명칭</div>
|
|
<div className="text-xs text-heading font-bold">{result.koreaName}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-surface-raised rounded-lg p-3 border border-slate-700/30">
|
|
<div className="text-[9px] text-hint mb-1">조치 사항</div>
|
|
<div className="text-xs text-heading font-medium">{result.actionRequired}</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 판별 근거 */}
|
|
<Card>
|
|
<CardHeader className="px-4 pt-3 pb-0">
|
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
|
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
|
|
판별 근거 ({result.reasons.length}건)
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="px-4 pb-4 pt-2">
|
|
<div className="space-y-1.5">
|
|
{result.reasons.map((reason, i) => (
|
|
<div key={i} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-md">
|
|
<ChevronRight className="w-3 h-3 text-green-500 mt-0.5 shrink-0" />
|
|
<span className="text-[11px] text-label">{reason}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 경고/위반 사항 */}
|
|
{result.warnings.length > 0 && (
|
|
<Card className="bg-surface-raised border-orange-500/20">
|
|
<CardHeader className="px-4 pt-3 pb-0">
|
|
<CardTitle className="text-xs text-orange-400 flex items-center gap-1.5">
|
|
<AlertTriangle className="w-3.5 h-3.5" />
|
|
경고 / 위반 사항 ({result.warnings.length}건)
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="px-4 pb-4 pt-2">
|
|
<div className="space-y-1.5">
|
|
{result.warnings.map((warning, i) => (
|
|
<div key={i} className="flex items-start gap-2 p-2 bg-orange-500/5 border border-orange-500/10 rounded-md">
|
|
<XCircle className="w-3 h-3 text-orange-500 mt-0.5 shrink-0" />
|
|
<span className="text-[11px] text-orange-300">{warning}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* AI 탐지 Rule 참조 */}
|
|
<Card>
|
|
<CardHeader className="px-4 pt-3 pb-0">
|
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
|
<Shield className="w-3.5 h-3.5 text-purple-500" />
|
|
AI 탐지 Rule (해당 어구)
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="px-4 pb-4 pt-2">
|
|
{result.gearType === 'trawl' && (
|
|
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
|
{`# 트롤 탐지 조건 (Trawl Detection Rule)
|
|
if speed in range(2.0, 5.0) # knots
|
|
and trajectory == 'parallel_sweep' # 반복 평행선
|
|
and turn_angle < 30 # 최대 회전각 (°)
|
|
and duration > 120 # 최소 지속 시간 (min)
|
|
and speed_std < 0.8 # 속도 분산 낮음
|
|
→ gear = 'TRAWL' confidence >= 89%
|
|
|
|
# 쌍선 트롤 추가 조건 (Pair Trawl — TDS)
|
|
and vessel_pair_distance in range(300, 800) # m
|
|
and speed_sync > 0.92 # 2선 속도 동기화`}
|
|
</pre>
|
|
)}
|
|
{result.gearType === 'gillnet' && (
|
|
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
|
{`# 자망 탐지 조건 (Gillnet Detection Rule)
|
|
if speed < 2.0 # knots
|
|
and stop_duration > 30 # min
|
|
and repeated_loc == True # 동일 위치 재방문
|
|
→ gear = 'GILLNET' confidence >= 74%
|
|
|
|
# AIS 소실 연계 다크베셀 탐지
|
|
if ais_gap_duration > 20 # min (AIS 공백)
|
|
and sar_vessel_detect == True # SAR 위치 확인
|
|
→ dark_vessel_flag = True
|
|
gear_estimate = 'GILLNET (추정)'`}
|
|
</pre>
|
|
)}
|
|
{result.gearType === 'purseSeine' && (
|
|
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
|
{`# 선망 탐지 조건 (Purse Seine Detection Rule)
|
|
if trajectory == 'circular' # 원형 궤적
|
|
and speed_change > 5.0 # kt (고→저 급변)
|
|
and speed_high_phase in range(8, 12) # 초기 고속
|
|
and speed_low_phase < 3.0 # 이후 저속
|
|
→ gear = 'PURSE_SEINE' confidence >= 94%
|
|
|
|
# Fleet 추가 확인
|
|
and multi_vessel_cluster == True # 3척 이상
|
|
and vessel_spacing < 1000 # m
|
|
→ fleet_type = 'PURSE_SEINE_FLEET'
|
|
confidence += 0.03 # 97%+`}
|
|
</pre>
|
|
)}
|
|
{result.gearType === 'setNet' && (
|
|
<pre className="bg-background rounded-lg p-3 text-[10px] text-red-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
|
{`# 정치망 — EEZ 내 중국어선 미허가 어구
|
|
# GB/T 코드: Z__ (ZD: 단묘장망, ZS: 복묘장망)
|
|
#
|
|
# 판별 기준:
|
|
# - 사각 프레임 또는 자루형 구조
|
|
# - 해저 고정 (묘박)
|
|
# - 선박 없이 어구만 발견 시 중국 투기 어구 추정
|
|
#
|
|
# → 발견 즉시 CRITICAL 처리
|
|
# → 즉시 수거 및 GPS 위치 기록
|
|
# → 반복 발견 위치는 집중 감시 구역 지정`}
|
|
</pre>
|
|
)}
|
|
{(result.gearType === 'trap' || result.gearType === 'unknown') && (
|
|
<div className="bg-surface-overlay rounded-lg p-3 text-[11px] text-muted-foreground">
|
|
해당 어구 유형의 AI 탐지 Rule이 정의되지 않았습니다. 현장 승선 검사를 통한 직접 확인이 필요합니다.
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 다중 센서 교차 검증 흐름 */}
|
|
<Card>
|
|
<CardHeader className="px-4 pt-3 pb-0">
|
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
|
<Waves className="w-3.5 h-3.5 text-cyan-500" />
|
|
다중 센서 교차 검증 파이프라인
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="px-4 pb-4 pt-2">
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
{[
|
|
{ step: 'RF 탐지', desc: '다크 신호 탐지 (HawkEye360)', color: '#f97316' },
|
|
{ step: 'SAR 확인', desc: '위성 SAR 위치 확인', color: '#a855f7' },
|
|
{ step: 'AIS 분석', desc: '속도·궤적·패턴 분류', color: '#3b82f6' },
|
|
{ step: 'EO 검증', desc: '광학 위성 어구 확인', color: '#10b981' },
|
|
].map((s, i) => (
|
|
<div key={i} className="flex items-center gap-1">
|
|
<div className="bg-surface-overlay border border-slate-700/40 rounded-md px-2.5 py-1.5 text-center">
|
|
<div className="text-[10px] font-bold" style={{ color: s.color }}>{s.step}</div>
|
|
<div className="text-[8px] text-hint">{s.desc}</div>
|
|
</div>
|
|
{i < 3 && <ArrowRight className="w-3 h-3 text-hint shrink-0" />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 최근 자동탐지 결과 (prediction 기반) */}
|
|
<AutoGearDetectionSection
|
|
onSelect={applyAutoDetection}
|
|
selectedId={autoSelected?.id ?? null}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 자동탐지 결과 섹션 ─────────────────
|
|
|
|
const GEAR_JUDGMENT_LABEL: Record<string, string> = {
|
|
CLOSED_SEASON_FISHING: '금어기 조업',
|
|
UNREGISTERED_GEAR: '미등록 어구',
|
|
GEAR_MISMATCH: '어구 불일치',
|
|
MULTIPLE_VIOLATION: '복합 위반',
|
|
NORMAL: '정상',
|
|
};
|
|
|
|
const GEAR_JUDGMENT_INTENT: Record<string, 'critical' | 'warning' | 'muted' | 'success'> = {
|
|
CLOSED_SEASON_FISHING: 'critical',
|
|
UNREGISTERED_GEAR: 'warning',
|
|
GEAR_MISMATCH: 'warning',
|
|
MULTIPLE_VIOLATION: 'critical',
|
|
NORMAL: 'success',
|
|
};
|
|
|
|
const PERMIT_STATUS_LABEL: Record<string, string> = {
|
|
PERMITTED: '허가',
|
|
UNPERMITTED: '미허가',
|
|
UNKNOWN: '확인불가',
|
|
};
|
|
|
|
function AutoGearDetectionSection({
|
|
onSelect,
|
|
selectedId,
|
|
}: {
|
|
onSelect: (v: GearDetection) => void;
|
|
selectedId: number | null;
|
|
}) {
|
|
const { t, i18n } = useTranslation('common');
|
|
const lang = (i18n.language as 'ko' | 'en') || 'ko';
|
|
const [items, setItems] = useState<GearDetection[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true); setError('');
|
|
try {
|
|
const page = await getGearDetections({ hours: 1, mmsiPrefix: '412', size: 50 });
|
|
setItems(page.content);
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : '조회 실패');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
return (
|
|
<Card>
|
|
<CardContent className="p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
|
<Radar className="w-4 h-4 text-cyan-500" />
|
|
최근 자동탐지 결과 (prediction, 최근 1시간 중국 선박)
|
|
</div>
|
|
<div className="text-[10px] text-hint mt-0.5">
|
|
GET /api/analysis/gear-detections · MMSI 412 · gear_code / gear_judgment NOT NULL · 행 클릭 시 상단 입력 폼에 프리필
|
|
</div>
|
|
</div>
|
|
<button type="button" onClick={load}
|
|
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
|
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
|
|
|
{!loading && (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-surface-overlay text-hint">
|
|
<tr>
|
|
<th className="px-2 py-1.5 text-left">MMSI</th>
|
|
<th className="px-2 py-1.5 text-left">선박유형</th>
|
|
<th className="px-2 py-1.5 text-center">어구코드</th>
|
|
<th className="px-2 py-1.5 text-center">판정</th>
|
|
<th className="px-2 py-1.5 text-center">허가</th>
|
|
<th className="px-2 py-1.5 text-left">해역</th>
|
|
<th className="px-2 py-1.5 text-center">위험도</th>
|
|
<th className="px-2 py-1.5 text-right">점수</th>
|
|
<th className="px-2 py-1.5 text-left">위반</th>
|
|
<th className="px-2 py-1.5 text-left">갱신</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.length === 0 && (
|
|
<tr><td colSpan={10} className="px-3 py-6 text-center text-hint">자동탐지된 어구 위반 결과가 없습니다.</td></tr>
|
|
)}
|
|
{items.map((v) => {
|
|
const selected = v.id === selectedId;
|
|
return (
|
|
<tr
|
|
key={v.id}
|
|
onClick={() => onSelect(v)}
|
|
className={`border-t border-border cursor-pointer transition-colors ${
|
|
selected ? 'bg-cyan-500/10 hover:bg-cyan-500/15' : 'hover:bg-surface-overlay/50'
|
|
}`}
|
|
title="클릭하면 상단 입력 폼에 자동으로 채워집니다"
|
|
>
|
|
<td className="px-2 py-1.5 text-cyan-400 font-mono">{v.mmsi}</td>
|
|
<td className="px-2 py-1.5 text-heading font-medium">{v.vesselType ?? '-'}</td>
|
|
<td className="px-2 py-1.5 text-center font-mono text-label">{v.gearCode}</td>
|
|
<td className="px-2 py-1.5 text-center">
|
|
<Badge intent={GEAR_JUDGMENT_INTENT[v.gearJudgment] ?? 'muted'} size="sm">
|
|
{GEAR_JUDGMENT_LABEL[v.gearJudgment] ?? v.gearJudgment}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-2 py-1.5 text-center text-[10px] text-muted-foreground">
|
|
{PERMIT_STATUS_LABEL[v.permitStatus ?? ''] ?? v.permitStatus ?? '-'}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
|
|
{v.zoneCode ? getZoneCodeLabel(v.zoneCode, t, lang) : '-'}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-center">
|
|
{v.riskLevel ? (
|
|
<Badge intent={getAlertLevelIntent(v.riskLevel)} size="sm">{v.riskLevel}</Badge>
|
|
) : <span className="text-hint">-</span>}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-right text-heading font-bold">{v.riskScore ?? '-'}</td>
|
|
<td className="px-2 py-1.5 text-[10px] text-muted-foreground">
|
|
{(v.violationCategories ?? []).join(', ') || '-'}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
|
|
{v.analyzedAt ? formatDateTime(v.analyzedAt) : '-'}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|