From c17d190e1dbb11be5aadfe93fc98a20b22edc5cb Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 7 Apr 2026 12:46:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20S5=20=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=EB=82=98=EB=A8=B8=EC=A7=80=20=ED=99=94=EB=A9=B4=20=EC=8B=A4?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=84=ED=99=98=20=E2=80=94=20?= =?UTF-8?q?=ED=83=90=EC=A7=80/=ED=95=A8=EC=A0=95/=EB=8B=A8=EC=86=8D?= =?UTF-8?q?=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탐지 화면 3개: - GearDetection: gearStore 더미 → fetchGroups() API (GEAR_IN/OUT_ZONE) - DarkVesselDetection: vesselStore 더미 → fetchVesselAnalysis() + filterDarkVessels() - 패턴 자동 분류 (완전차단/장기소실/MMSI변조/간헐송출) - ChinaFishing: inline 더미 → fetchVesselAnalysis() + mmsi 412* 필터 - 센서 카운터 동적 계산, 위험도 분포 도넛 차트 함정/단속계획: - patrol.ts: 스텁 → GET /api/patrol-ships 실제 호출 - patrolStore: API 기반 (routes/scenarios는 mock 유지) - EnforcementPlan: GET /api/enforcement/plans 연결 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/features/detection/ChinaFishing.tsx | 277 +++++++++++------- .../detection/DarkVesselDetection.tsx | 149 +++++++--- .../src/features/detection/GearDetection.tsx | 105 +++++-- .../risk-assessment/EnforcementPlan.tsx | 85 +++++- frontend/src/services/index.ts | 3 +- frontend/src/services/patrol.ts | 71 ++++- frontend/src/stores/patrolStore.ts | 57 ++-- 7 files changed, 533 insertions(+), 214 deletions(-) diff --git a/frontend/src/features/detection/ChinaFishing.tsx b/frontend/src/features/detection/ChinaFishing.tsx index e3926bc..076d2fa 100644 --- a/frontend/src/features/detection/ChinaFishing.tsx +++ b/frontend/src/features/detection/ChinaFishing.tsx @@ -1,32 +1,31 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud, - Eye, AlertTriangle, ShieldCheck, Radio, Anchor, RotateCcw, - MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon + Eye, AlertTriangle, Radio, RotateCcw, + MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2 } from 'lucide-react'; import { GearIdentification } from './GearIdentification'; import { RealAllVessels } from './RealVesselAnalysis'; -import { BaseChart, PieChart as EcPieChart } from '@lib/charts'; -import type { EChartsOption } from 'echarts'; +import { PieChart as EcPieChart } from '@lib/charts'; import { useTransferStore } from '@stores/transferStore'; +import { + fetchVesselAnalysis, + filterDarkVessels, + filterTransshipSuspects, + type VesselAnalysisItem, + type VesselAnalysisStats, +} from '@/services/vesselAnalysisApi'; -// ─── 센서 카운터 (시안 2행) ───────────── -const COUNTERS_ROW1 = [ - { label: '통합', count: 1350, color: '#6b7280', icon: '🔵' }, - { label: 'AIS', count: 2212, color: '#3b82f6', icon: '🟢' }, - { label: 'E-Nav', count: 745, color: '#8b5cf6', icon: '🔷' }, - { label: '여객선', count: 1, color: '#10b981', icon: '🟡' }, -]; -const COUNTERS_ROW2 = [ - { label: '중국어선', count: 20, color: '#f97316', icon: '🟠' }, - { label: 'V-PASS', count: 465, color: '#06b6d4', icon: '🟢' }, - { label: '함정', count: 2, color: '#6b7280', icon: '🔵' }, - { label: '위험물', count: 0, color: '#6b7280', icon: '⚪' }, -]; +// ─── 중국 MMSI prefix ───────────── +const CHINA_MMSI_PREFIX = '412'; -// ─── 특이운항 선박 리스트 ──────────────── +function isChinaVessel(mmsi: string): boolean { + return mmsi.startsWith(CHINA_MMSI_PREFIX); +} + +// ─── 특이운항 선박 리스트 타입 ──────────────── type VesselStatus = '의심' | '양호' | '경고'; interface VesselItem { id: string; @@ -41,30 +40,27 @@ interface VesselItem { riskPct: number; } -const VESSEL_LIST: VesselItem[] = [ - { id: '1', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 44 }, - { id: '2', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '양호', riskPct: 70 }, - { id: '3', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 24 }, - { id: '4', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '경고', riskPct: 84 }, - { id: '5', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 44 }, - { id: '6', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 44 }, -]; +function deriveVesselStatus(score: number): VesselStatus { + if (score >= 70) return '경고'; + if (score >= 40) return '의심'; + return '양호'; +} -// ─── 월별 불법조업 통계 ────────────────── -const MONTHLY_DATA = [ - { month: 'JAN', 범장망: 45, 쌍끌이: 30, 외끌이: 20, 트롤: 10 }, - { month: 'FEB', 범장망: 55, 쌍끌이: 35, 외끌이: 25, 트롤: 15 }, - { month: 'MAR', 범장망: 70, 쌍끌이: 45, 외끌이: 30, 트롤: 20 }, - { month: 'APR', 범장망: 85, 쌍끌이: 50, 외끌이: 35, 트롤: 25 }, - { month: 'MAY', 범장망: 95, 쌍끌이: 55, 외끌이: 40, 트롤: 30 }, - { month: 'JUN', 범장망: 80, 쌍끌이: 45, 외끌이: 35, 트롤: 22 }, - { month: 'JUL', 범장망: 60, 쌍끌이: 35, 외끌이: 25, 트롤: 18 }, - { month: 'AUG', 범장망: 50, 쌍끌이: 30, 외끌이: 20, 트롤: 12 }, - { month: 'SEP', 범장망: 65, 쌍끌이: 40, 외끌이: 28, 트롤: 20 }, - { month: 'OCT', 범장망: 75, 쌍끌이: 48, 외끌이: 32, 트롤: 22 }, - { month: 'NOV', 범장망: 90, 쌍끌이: 52, 외끌이: 38, 트롤: 28 }, - { month: 'DEC', 범장망: 100, 쌍끌이: 60, 외끌이: 42, 트롤: 30 }, -]; +function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem { + const score = item.algorithms.riskScore.score; + return { + id: String(idx + 1), + mmsi: item.mmsi, + callSign: '-', + channel: '', + source: 'AIS', + name: item.classification.vesselType || item.mmsi, + type: item.classification.fishingPct > 0.5 ? 'Fishing' : 'Cargo', + country: 'China', + status: deriveVesselStatus(score), + riskPct: score, + }; +} // ─── VTS 연계 항목 ───────────────────── const VTS_ITEMS = [ @@ -299,6 +295,81 @@ export function ChinaFishing() { const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항'); const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계'); + // API state + const [allItems, setAllItems] = useState([]); + const [apiStats, setApiStats] = useState(null); + const [serviceAvailable, setServiceAvailable] = useState(true); + const [apiLoading, setApiLoading] = useState(false); + const [apiError, setApiError] = useState(''); + + const loadApi = useCallback(async () => { + setApiLoading(true); + setApiError(''); + try { + const res = await fetchVesselAnalysis(); + setServiceAvailable(res.serviceAvailable); + setAllItems(res.items); + setApiStats(res.stats); + } catch (e: unknown) { + setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다'); + setServiceAvailable(false); + } finally { + setApiLoading(false); + } + }, []); + + useEffect(() => { loadApi(); }, [loadApi]); + + // 중국어선 필터 + const chinaVessels = useMemo( + () => allItems.filter((i) => isChinaVessel(i.mmsi)), + [allItems], + ); + + const chinaDark = useMemo(() => filterDarkVessels(chinaVessels), [chinaVessels]); + const chinaTransship = useMemo(() => filterTransshipSuspects(chinaVessels), [chinaVessels]); + + // 센서 카운터 (API 기반) + const countersRow1 = useMemo(() => [ + { label: '통합', count: allItems.length, color: '#6b7280' }, + { label: 'AIS', count: allItems.length, color: '#3b82f6' }, + { label: 'EEZ 내', count: allItems.filter((i) => i.algorithms.location.zone !== 'EEZ_OR_BEYOND').length, color: '#8b5cf6' }, + { label: '어업선', count: allItems.filter((i) => i.classification.fishingPct > 0.5).length, color: '#10b981' }, + ], [allItems]); + + const countersRow2 = useMemo(() => [ + { label: '중국어선', count: chinaVessels.length, color: '#f97316' }, + { label: 'Dark Vessel', count: chinaDark.length, color: '#ef4444' }, + { label: '환적 의심', count: chinaTransship.length, color: '#06b6d4' }, + { label: '고위험', count: chinaVessels.filter((i) => i.algorithms.riskScore.score >= 70).length, color: '#ef4444' }, + ], [chinaVessels, chinaDark, chinaTransship]); + + // 특이운항 선박 리스트 (중국어선 중 riskScore >= 40) + const vesselList: VesselItem[] = useMemo( + () => chinaVessels + .filter((i) => i.algorithms.riskScore.score >= 40) + .sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score) + .slice(0, 20) + .map((item, idx) => mapToVesselItem(item, idx)), + [chinaVessels], + ); + + // 위험도별 분포 (도넛 차트용) + const riskDistribution = useMemo(() => { + const critical = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length; + const high = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'HIGH').length; + const medium = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length; + const low = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'LOW').length; + return { critical, high, medium, low, total: chinaVessels.length }; + }, [chinaVessels]); + + // 안전도 지수 계산 + const safetyIndex = useMemo(() => { + if (chinaVessels.length === 0) return { risk: 0, safety: 100 }; + const avgRisk = chinaVessels.reduce((s, i) => s + i.algorithms.riskScore.score, 0) / chinaVessels.length; + return { risk: Number((avgRisk / 10).toFixed(2)), safety: Number(((100 - avgRisk) / 10).toFixed(2)) }; + }, [chinaVessels]); + const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const; const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const; @@ -337,6 +408,21 @@ export function ChinaFishing() { {/* AI 대시보드 모드 */} {mode === 'dashboard' && <> + {!serviceAvailable && ( +
+ + iran 분석 서비스 미연결 - 실시간 데이터를 불러올 수 없습니다 +
+ )} + + {apiError &&
에러: {apiError}
} + + {apiLoading && ( +
+ +
+ )} + {/* iran 백엔드 실시간 분석 결과 */} @@ -344,9 +430,9 @@ export function ChinaFishing() {
- 기준 : 2023-09-25 14:56 + 기준 : {new Date().toLocaleString('ko-KR')}
-
@@ -368,20 +454,22 @@ export function ChinaFishing() {
해역별 통항량 -
- 해구번호 - 123-456 -
+ {apiStats && ( +
+ 분석 대상 + {apiStats.total.toLocaleString()}척 +
+ )}
해역 전체 통항량 - 12,454 + {allItems.length.toLocaleString()} (척)
{/* 카운터 Row 1 */}
- {COUNTERS_ROW1.map((c) => ( + {countersRow1.map((c) => (
{c.label}
{c.count.toLocaleString()}
@@ -390,10 +478,10 @@ export function ChinaFishing() {
{/* 카운터 Row 2 */}
- {COUNTERS_ROW2.map((c) => ( + {countersRow2.map((c) => (
{c.label}
-
0 ? '#e5e7eb' : '#4b5563' }}> +
0 ? 'text-heading' : 'text-muted'}`}> {c.count > 0 ? c.count.toLocaleString() : '-'}
@@ -413,13 +501,13 @@ export function ChinaFishing() {
종합 위험지수
- +
종합 안전지수
- +
@@ -457,7 +545,7 @@ export function ChinaFishing() { 정상
- + 0 ? Number(((1 - riskDistribution.critical / Math.max(chinaVessels.length, 1)) * 100).toFixed(1)) : 100} label="" />
@@ -490,7 +578,12 @@ export function ChinaFishing() { {/* 선박 목록 */}
- {VESSEL_LIST.map((v) => ( + {vesselList.length === 0 && ( +
+ {apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'} +
+ )} + {vesselList.map((v) => (
- ID | {v.mmsi} - 호출부호 | {v.callSign} + MMSI | {v.mmsi} 출처 | {v.source}
@@ -507,7 +599,6 @@ export function ChinaFishing() { {v.type}
- 🇰🇷 {v.country}
@@ -543,75 +634,45 @@ export function ChinaFishing() {
- {/* 바 차트 */} -
- d.month) }, - yAxis: { type: 'value' }, - series: [ - { name: '범장망', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.범장망), itemStyle: { color: '#22c55e' } }, - { name: '쌍끌이', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.쌍끌이), itemStyle: { color: '#f97316' } }, - { name: '외끌이', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.외끌이), itemStyle: { color: '#60a5fa' } }, - { name: '트롤', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.트롤), itemStyle: { color: '#6b7280', borderRadius: [2, 2, 0, 0] } }, - ], - } as EChartsOption} /> - - {/* 범례 */} -
- {[ - { label: '범장망 선박', color: '#22c55e' }, - { label: '쌍끌이 선박', color: '#f97316' }, - { label: '외끌이 선박', color: '#60a5fa' }, - { label: '트롤 선박', color: '#6b7280' }, - ].map((l) => ( - - - {l.label} - - ))} + {/* 월별 통계 - API 미지원, 준비중 안내 */} +
+
월별 불법조업 통계
+
+ 월별 집계 API 연동 준비중입니다. 실시간 위험도 분포는 우측 도넛을 참고하세요.
- {/* 도넛 2개 */} -
+ {/* 위험도 분포 도넛 */} +
- 356 - TOTAL + {riskDistribution.total} + 중국어선
-
- -
- 356 - TOTAL -
+
+
CRITICAL {riskDistribution.critical}
+
HIGH {riskDistribution.high}
+
MEDIUM {riskDistribution.medium}
+
LOW {riskDistribution.low}
{/* 다운로드 버튼 */}
-
diff --git a/frontend/src/features/detection/DarkVesselDetection.tsx b/frontend/src/features/detection/DarkVesselDetection.tsx index 5ef7a4e..4ded27c 100644 --- a/frontend/src/features/detection/DarkVesselDetection.tsx +++ b/frontend/src/features/detection/DarkVesselDetection.tsx @@ -1,38 +1,77 @@ -import { useEffect, useMemo, useRef, useCallback } from 'react'; +import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; -import { Eye, EyeOff, AlertTriangle, Ship, Radar, Radio, Target, Shield, Tag } from 'lucide-react'; +import { EyeOff, AlertTriangle, Radio, Tag, Loader2 } from 'lucide-react'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { MarkerData } from '@lib/map'; -import { useVesselStore } from '@stores/vesselStore'; -import { RealDarkVessels, RealSpoofingVessels } from './RealVesselAnalysis'; +import { + fetchVesselAnalysis, + filterDarkVessels, + type VesselAnalysisItem, +} from '@/services/vesselAnalysisApi'; /* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */ interface Suspect { id: string; mmsi: string; name: string; flag: string; pattern: string; risk: number; lastAIS: string; status: string; label: string; lat: number; lng: number; [key: string]: unknown; } -const FLAG_MAP: Record = { CN: '중국', KR: '한국', UNKNOWN: '미상' }; +const GAP_FULL_BLOCK_MIN = 1440; +const GAP_LONG_LOSS_MIN = 60; +const SPOOFING_THRESHOLD = 0.7; + +function derivePattern(item: VesselAnalysisItem): string { + const { gapDurationMin } = item.algorithms.darkVessel; + const { spoofingScore } = item.algorithms.gpsSpoofing; + if (gapDurationMin > GAP_FULL_BLOCK_MIN) return 'AIS 완전차단'; + if (spoofingScore > SPOOFING_THRESHOLD) return 'MMSI 변조 의심'; + if (gapDurationMin > GAP_LONG_LOSS_MIN) return '장기소실'; + return '신호 간헐송출'; +} + +function deriveStatus(item: VesselAnalysisItem): string { + const { score } = item.algorithms.riskScore; + if (score >= 80) return '추적중'; + if (score >= 50) return '감시중'; + if (score >= 30) return '확인중'; + return '정상'; +} + +function deriveFlag(mmsi: string): string { + if (mmsi.startsWith('412')) return '중국'; + if (mmsi.startsWith('440') || mmsi.startsWith('441')) return '한국'; + return '미상'; +} + +function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect { + const risk = item.algorithms.riskScore.score; + const status = deriveStatus(item); + return { + id: `DV-${String(idx + 1).padStart(3, '0')}`, + mmsi: item.mmsi, + name: item.classification.vesselType || item.mmsi, + flag: deriveFlag(item.mmsi), + pattern: derivePattern(item), + risk, + lastAIS: item.timestamp ? new Date(item.timestamp).toLocaleString('ko-KR') : '-', + status, + label: risk >= 90 ? (status === '추적중' ? '불법' : '-') : status === '정상' ? '정상' : '-', + lat: 0, + lng: 0, + }; +} const PATTERN_COLORS: Record = { 'AIS 완전차단': '#ef4444', - 'MMSI 3회 변경': '#f97316', - '급격 속력변화': '#eab308', + 'MMSI 변조 의심': '#f97316', + '장기소실': '#eab308', '신호 간헐송출': '#a855f7', - '비정기 신호': '#3b82f6', - '국적 위장 의심': '#ec4899', -}; -const STATUS_COLORS: Record = { - '추적중': '#ef4444', - '감시중': '#eab308', - '확인중': '#3b82f6', - '정상': '#22c55e', }; + const cols: DataColumn[] = [ { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, { key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, render: v => {v as string} }, - { key: 'name', label: '선박명', sortable: true, render: v => {v as string} }, + { key: 'name', label: '선박 유형', sortable: true, render: v => {v as string} }, { key: 'mmsi', label: 'MMSI', width: '100px', render: v => {v as string} }, { key: 'flag', label: '국적', width: '50px' }, { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, @@ -46,33 +85,42 @@ const cols: DataColumn[] = [ export function DarkVesselDetection() { const { t } = useTranslation('detection'); - const { suspects, loaded, load } = useVesselStore(); - useEffect(() => { if (!loaded) load(); }, [loaded, load]); + const [darkItems, setDarkItems] = useState([]); + const [serviceAvailable, setServiceAvailable] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const loadData = useCallback(async () => { + setLoading(true); + setError(''); + try { + const res = await fetchVesselAnalysis(); + setServiceAvailable(res.serviceAvailable); + setDarkItems(filterDarkVessels(res.items)); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다'); + setServiceAvailable(false); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadData(); }, [loadData]); - // Map VesselData to local Suspect shape const DATA: Suspect[] = useMemo( - () => - suspects.map((v) => ({ - id: v.id, - mmsi: v.mmsi, - name: v.name, - flag: FLAG_MAP[v.flag] ?? v.flag, - pattern: v.pattern ?? '-', - risk: v.risk, - lastAIS: v.lastSignal ?? '-', - status: v.status, - label: v.risk >= 90 ? (v.status === '추적중' ? '불법' : '-') : v.status === '정상' ? '정상' : '-', - lat: v.lat, - lng: v.lng, - })), - [suspects], + () => darkItems.map((item, i) => mapItemToSuspect(item, i)), + [darkItems], + ); + + const avgRisk = useMemo( + () => DATA.length > 0 ? Math.round(DATA.reduce((s, d) => s + d.risk, 0) / DATA.length) : 0, + [DATA], ); const mapRef = useRef(null); const buildLayers = useCallback(() => [ ...STATIC_LAYERS, - // 경보 반경 (고위험만) createRadiusLayer( 'dv-radius', DATA.filter(d => d.risk > 80).map(d => ({ @@ -83,7 +131,6 @@ export function DarkVesselDetection() { })), 0.08, ), - // 탐지 선박 마커 createMarkerLayer( 'dv-markers', DATA.map(d => ({ @@ -106,22 +153,36 @@ export function DarkVesselDetection() {

{t('darkVessel.desc')}

+ + {!serviceAvailable && ( +
+ + iran 분석 서비스 미연결 - 실시간 Dark Vessel 데이터를 불러올 수 없습니다 +
+ )} + + {error &&
에러: {error}
} + + {loading && ( +
+ +
+ )} +
- {[{ l: '의심 선박', v: DATA.filter(d => d.risk > 50).length, c: 'text-red-400', i: AlertTriangle }, - { l: 'Dark Vessel', v: DATA.filter(d => d.pattern.includes('차단')).length, c: 'text-orange-400', i: EyeOff }, - { l: 'MMSI 변조', v: DATA.filter(d => d.pattern.includes('MMSI')).length, c: 'text-yellow-400', i: Radio }, - { l: '라벨링 완료', v: DATA.filter(d => d.label !== '-').length + '/' + DATA.length, c: 'text-cyan-400', i: Tag }, + {[ + { l: 'Dark Vessel', v: DATA.length, c: 'text-red-400', i: AlertTriangle }, + { l: 'AIS 완전차단', v: DATA.filter(d => d.pattern === 'AIS 완전차단').length, c: 'text-orange-400', i: EyeOff }, + { l: 'MMSI 변조', v: DATA.filter(d => d.pattern === 'MMSI 변조 의심').length, c: 'text-yellow-400', i: Radio }, + { l: `평균 위험도`, v: avgRisk, c: 'text-cyan-400', i: Tag }, ].map(k => (
{k.v}{k.l}
))}
- {/* iran 백엔드 실시간 Dark Vessel + GPS 스푸핑 */} - - - + {/* 탐지 위치 지도 */} diff --git a/frontend/src/features/detection/GearDetection.tsx b/frontend/src/features/detection/GearDetection.tsx index 2717c43..2403f65 100644 --- a/frontend/src/features/detection/GearDetection.tsx +++ b/frontend/src/features/detection/GearDetection.tsx @@ -1,13 +1,12 @@ -import { useEffect, useMemo, useRef, useCallback } from 'react'; +import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; -import { Anchor, MapPin, AlertTriangle, CheckCircle, Clock, Ship, Filter } from 'lucide-react'; +import { Anchor, AlertTriangle, Loader2 } from 'lucide-react'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { MarkerData } from '@lib/map'; -import { useGearStore } from '@stores/gearStore'; -import { RealGearGroups } from './RealGearGroups'; +import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi'; /* SFR-10: 불법 어망·어구 탐지 및 관리 */ @@ -19,14 +18,36 @@ const RISK_COLORS: Record = { '안전': '#22c55e', }; -const GEAR_ICONS: Record = { - '저층트롤': '🔴', - '유자망': '🟠', - '유자망(대형)': '🔴', - '통발': '🟢', - '선망': '🟡', - '연승': '🔵', -}; +function deriveRisk(g: GearGroupItem): string { + if (g.resolution?.status === 'REVIEW_REQUIRED') return '고위험'; + if (g.resolution?.status === 'UNRESOLVED') return '중위험'; + return '안전'; +} + +function deriveStatus(g: GearGroupItem): string { + if (g.resolution?.status === 'REVIEW_REQUIRED') return '불법 의심'; + if (g.resolution?.status === 'UNRESOLVED') return '확인 중'; + if (g.resolution?.status === 'MANUAL_CONFIRMED') return '정상'; + return '확인 중'; +} + +function mapGroupToGear(g: GearGroupItem, idx: number): Gear { + const risk = deriveRisk(g); + const status = deriveStatus(g); + return { + id: `G-${String(idx + 1).padStart(3, '0')}`, + type: g.groupLabel || (g.groupType === 'GEAR_IN_ZONE' ? '지정해역 어구' : '지정해역 외 어구'), + owner: g.members[0]?.name || g.members[0]?.mmsi || '-', + zone: g.groupType === 'GEAR_IN_ZONE' ? '지정해역' : '지정해역 외', + status, + permit: 'NONE', + installed: g.snapshotTime ? new Date(g.snapshotTime).toLocaleDateString('ko-KR') : '-', + lastSignal: g.snapshotTime ? new Date(g.snapshotTime).toLocaleTimeString('ko-KR') : '-', + risk, + lat: g.centerLat, + lng: g.centerLon, + }; +} const cols: DataColumn[] = [ { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, @@ -44,17 +65,39 @@ const cols: DataColumn[] = [ export function GearDetection() { const { t } = useTranslation('detection'); - const { items, loaded, load } = useGearStore(); - useEffect(() => { if (!loaded) load(); }, [loaded, load]); + const [groups, setGroups] = useState([]); + const [serviceAvailable, setServiceAvailable] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); - // GearRecord from the store matches the local Gear shape exactly - const DATA: Gear[] = items as unknown as Gear[]; + const loadData = useCallback(async () => { + setLoading(true); + setError(''); + try { + const res = await fetchGroups(); + setServiceAvailable(res.serviceAvailable); + setGroups(res.items.filter( + (i) => i.groupType === 'GEAR_IN_ZONE' || i.groupType === 'GEAR_OUT_ZONE', + )); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다'); + setServiceAvailable(false); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadData(); }, [loadData]); + + const DATA: Gear[] = useMemo( + () => groups.map((g, i) => mapGroupToGear(g, i)), + [groups], + ); const mapRef = useRef(null); const buildLayers = useCallback(() => [ ...STATIC_LAYERS, - // 어구 설치 영역 (고위험만) createRadiusLayer( 'gear-radius', DATA.filter(g => g.risk === '고위험').map(g => ({ @@ -65,7 +108,6 @@ export function GearDetection() { })), 0.1, ), - // 어구 마커 createMarkerLayer( 'gear-markers', DATA.map(g => ({ @@ -86,15 +128,36 @@ export function GearDetection() {

{t('gearDetection.title')}

{t('gearDetection.desc')}

+ + {!serviceAvailable && ( +
+ + iran 분석 서비스 미연결 - 실시간 어구 데이터를 불러올 수 없습니다 +
+ )} + + {error && ( +
에러: {error}
+ )} + + {loading && ( +
+ +
+ )} +
- {[{ l: '전체 어구', v: DATA.length, c: 'text-heading' }, { l: '불법 의심', v: DATA.filter(d => d.status.includes('불법')).length, c: 'text-red-400' }, { l: '확인 중', v: DATA.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' }, { l: '정상', v: DATA.filter(d => d.status === '정상').length, c: 'text-green-400' }].map(k => ( + {[ + { l: '전체 어구 그룹', v: DATA.length, c: 'text-heading' }, + { l: '불법 의심', v: DATA.filter(d => d.status.includes('불법')).length, c: 'text-red-400' }, + { l: '확인 중', v: DATA.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' }, + { l: '정상', v: DATA.filter(d => d.status === '정상').length, c: 'text-green-400' }, + ].map(k => (
{k.v}{k.l}
))}
- {/* iran 백엔드 실시간 어구/선단 그룹 */} - diff --git a/frontend/src/features/risk-assessment/EnforcementPlan.tsx b/frontend/src/features/risk-assessment/EnforcementPlan.tsx index 75a456e..d2ed00e 100644 --- a/frontend/src/features/risk-assessment/EnforcementPlan.tsx +++ b/frontend/src/features/risk-assessment/EnforcementPlan.tsx @@ -1,17 +1,33 @@ -import { useEffect, useMemo, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; -import { Shield, AlertTriangle, Clock, MapPin, Ship, Bell, Plus, Target, Calendar, Users } from 'lucide-react'; +import { Shield, AlertTriangle, Ship, Plus, Calendar, Users } from 'lucide-react'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { MarkerData } from '@lib/map'; -import { useEnforcementStore } from '@stores/enforcementStore'; +import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement'; /* SFR-06: 단속 계획·경보 연계(단속 우선지역 예보) */ interface Plan { id: string; zone: string; lat: number; lng: number; risk: number; period: string; ships: string; crew: number; status: string; alert: string; [key: string]: unknown; } +/** API 응답 → 화면용 Plan 변환 */ +function toPlan(p: EnforcementPlanApi): Plan { + return { + id: p.planUid, + zone: p.areaName ?? p.zoneCode ?? '-', + lat: p.lat ?? 0, + lng: p.lon ?? 0, + risk: p.riskScore ?? 0, + period: p.plannedDate, + ships: `${p.assignedShipCount}척`, + crew: p.assignedCrew, + status: p.status, + alert: p.alertStatus ?? '-', + }; +} + const cols: DataColumn[] = [ { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, { key: 'zone', label: '단속 구역', sortable: true, render: v => {v as string} }, @@ -21,20 +37,38 @@ const cols: DataColumn[] = [ { key: 'ships', label: '참여 함정', render: v => {v as string} }, { key: 'crew', label: '인력', width: '50px', align: 'right', render: v => {v as number || '-'} }, { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, - render: v => { const s = v as string; return {s}; } }, + render: v => { const s = v as string; return {s}; } }, { key: 'alert', label: '경보', width: '80px', align: 'center', - render: v => { const a = v as string; return a === '경보 발령' ? {a} : {a}; } }, + render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? {a} : {a}; } }, ]; export function EnforcementPlan() { const { t } = useTranslation('enforcement'); - const { plans: storePlans, load } = useEnforcementStore(); - useEffect(() => { load(); }, [load]); - const PLANS: Plan[] = useMemo( - () => storePlans.map((p) => ({ ...p } as Plan)), - [storePlans], - ); + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + getEnforcementPlans({ size: 100 }) + .then((res) => { + if (!cancelled) { + setPlans(res.content.map(toPlan)); + setLoading(false); + } + }) + .catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + } + }); + return () => { cancelled = true; }; + }, []); + + const PLANS = plans; const mapRef = useRef(null); @@ -42,7 +76,7 @@ export function EnforcementPlan() { ...STATIC_LAYERS, createRadiusLayer( 'ep-radius-confirmed', - PLANS.filter(p => p.status === '확정').map(p => ({ + PLANS.filter(p => p.status === '확정' || p.status === 'CONFIRMED').map(p => ({ lat: p.lat, lng: p.lng, radius: 20000, @@ -52,7 +86,7 @@ export function EnforcementPlan() { ), createRadiusLayer( 'ep-radius-planned', - PLANS.filter(p => p.status !== '확정').map(p => ({ + PLANS.filter(p => p.status !== '확정' && p.status !== 'CONFIRMED').map(p => ({ lat: p.lat, lng: p.lng, radius: 20000, @@ -74,6 +108,15 @@ export function EnforcementPlan() { useMapLayers(mapRef, buildLayers, [PLANS]); + // 통계 요약값 + const todayCount = PLANS.length; + const alertCount = PLANS.filter(p => p.alert === '경보 발령' || p.alert === 'ALERT').length; + const totalShips = PLANS.reduce((sum, p) => { + const num = parseInt(p.ships, 10); + return sum + (isNaN(num) ? 0 : num); + }, 0); + const totalCrew = PLANS.reduce((sum, p) => sum + p.crew, 0); + return (
@@ -83,8 +126,22 @@ export function EnforcementPlan() {
+ + {/* 로딩/에러 상태 */} + {loading && ( +
단속 계획을 불러오는 중...
+ )} + {error && ( +
로드 실패: {error}
+ )} +
- {[{ l: '오늘 계획', v: '3건', c: 'text-heading', i: Calendar }, { l: '경보 발령', v: '1건', c: 'text-red-400', i: AlertTriangle }, { l: '투입 함정', v: '4척', c: 'text-cyan-400', i: Ship }, { l: '투입 인력', v: '90명', c: 'text-green-400', i: Users }].map(k => ( + {[ + { l: '오늘 계획', v: `${todayCount}건`, c: 'text-heading', i: Calendar }, + { l: '경보 발령', v: `${alertCount}건`, c: 'text-red-400', i: AlertTriangle }, + { l: '투입 함정', v: `${totalShips}척`, c: 'text-cyan-400', i: Ship }, + { l: '투입 인력', v: `${totalCrew}명`, c: 'text-green-400', i: Users }, + ].map(k => (
{k.v}{k.l}
diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index cb71484..a776117 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -4,7 +4,8 @@ export { getEvents, getEventById, ackEvent, updateEventStatus, getEventStats } f export type { PredictionEvent, EventPageResponse, EventStats } from './event'; export { getEnforcementRecords, createEnforcementRecord, getEnforcementPlans } from './enforcement'; export type { EnforcementRecord, EnforcementPlan } from './enforcement'; -export { getPatrolShips } from './patrol'; +export { getPatrolShips, updatePatrolShipStatus, toLegacyPatrolShip } from './patrol'; +export type { PatrolShipApi } from './patrol'; export { getKpiMetrics, getMonthlyStats, diff --git a/frontend/src/services/patrol.ts b/frontend/src/services/patrol.ts index 4dc010e..e9306ba 100644 --- a/frontend/src/services/patrol.ts +++ b/frontend/src/services/patrol.ts @@ -1,10 +1,71 @@ /** - * ���비함정/순찰 API 서비스 + * 경비함정/순찰 API 서비스 -- 실제 백엔드 연동 */ import type { PatrolShip } from '@data/mock/patrols'; -import { MOCK_PATROL_SHIPS } from '@data/mock/patrols'; -/** TODO: GET /api/v1/patrols/ships */ -export async function getPatrolShips(): Promise { - return MOCK_PATROL_SHIPS; +const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; + +// ─── 서버 응답 타입 ─────────────────────────────── + +export interface PatrolShipApi { + shipId: number; + shipCode: string; + shipName: string; + shipClass: string; + tonnage: number | null; + maxSpeedKn: number | null; + fuelCapacityL: number | null; + basePort: string | null; + currentStatus: string; + currentLat: number | null; + currentLon: number | null; + currentZoneCode: string | null; + fuelPct: number | null; + crewCount: number | null; + isActive: boolean; +} + +// ─── API 호출 ───────────────────────────────────── + +export async function getPatrolShips(): Promise { + const res = await fetch(`${API_BASE}/patrol-ships`, { credentials: 'include' }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +export async function updatePatrolShipStatus( + id: number, + data: { + status: string; + lat?: number; + lon?: number; + zoneCode?: string; + fuelPct?: number; + }, +): Promise { + const res = await fetch(`${API_BASE}/patrol-ships/${id}/status`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +// ─── 하위 호환 헬퍼 (기존 PatrolShip 형식 → API 응답 매핑) ── + +/** PatrolShipApi → PatrolShip (레거시) 변환 */ +export function toLegacyPatrolShip(s: PatrolShipApi): PatrolShip { + return { + id: s.shipCode, + name: s.shipName, + shipClass: s.shipClass, + speed: s.maxSpeedKn ?? 0, + status: s.currentStatus, + lat: s.currentLat ?? 0, + lng: s.currentLon ?? 0, + fuel: s.fuelPct ?? 0, + zone: s.currentZoneCode ?? undefined, + }; } diff --git a/frontend/src/stores/patrolStore.ts b/frontend/src/stores/patrolStore.ts index 1d6318c..23f9c37 100644 --- a/frontend/src/stores/patrolStore.ts +++ b/frontend/src/stores/patrolStore.ts @@ -5,6 +5,7 @@ import type { PatrolScenario, CoverageZone, } from '@data/mock/patrols'; +import { getPatrolShips, toLegacyPatrolShip } from '@/services/patrol'; interface PatrolStore { ships: PatrolShip[]; @@ -14,7 +15,9 @@ interface PatrolStore { fleetRoutes: Record; selectedShipId: string | null; loaded: boolean; - load: () => void; + loading: boolean; + error: string | null; + load: () => Promise; selectShip: (id: string | null) => void; } @@ -26,27 +29,39 @@ export const usePatrolStore = create((set, get) => ({ fleetRoutes: {}, selectedShipId: null, loaded: false, + loading: false, + error: null, - load: () => { - if (get().loaded) return; - import('@data/mock/patrols').then( - ({ - MOCK_PATROL_SHIPS, - MOCK_PATROL_ROUTES, - MOCK_PATROL_SCENARIOS, - MOCK_COVERAGE_ZONES, - MOCK_FLEET_ROUTES, - }) => { - set({ - ships: MOCK_PATROL_SHIPS, - routes: MOCK_PATROL_ROUTES, - scenarios: MOCK_PATROL_SCENARIOS, - coverage: MOCK_COVERAGE_ZONES, - fleetRoutes: MOCK_FLEET_ROUTES, - loaded: true, - }); - }, - ); + load: async () => { + if (get().loaded && !get().error) return; + + set({ loading: true, error: null }); + try { + // 함정 목록은 API에서, 나머지(routes/scenarios/coverage)는 mock 유지 + const [apiShips, mockModule] = await Promise.all([ + getPatrolShips(), + get().routes && Object.keys(get().routes).length > 0 + ? Promise.resolve(null) + : import('@data/mock/patrols').then((m) => ({ + routes: m.MOCK_PATROL_ROUTES, + scenarios: m.MOCK_PATROL_SCENARIOS, + coverage: m.MOCK_COVERAGE_ZONES, + fleetRoutes: m.MOCK_FLEET_ROUTES, + })), + ]); + + set({ + ships: apiShips.map(toLegacyPatrolShip), + routes: mockModule?.routes ?? get().routes, + scenarios: mockModule?.scenarios ?? get().scenarios, + coverage: mockModule?.coverage ?? get().coverage, + fleetRoutes: mockModule?.fleetRoutes ?? get().fleetRoutes, + loaded: true, + loading: false, + }); + } catch (err) { + set({ error: err instanceof Error ? err.message : String(err), loading: false }); + } }, selectShip: (id) => set({ selectedShipId: id }),