diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/EventController.java b/backend/src/main/java/gc/mda/kcg/domain/event/EventController.java index f9b2a17..a441211 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/event/EventController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/event/EventController.java @@ -34,11 +34,12 @@ public class EventController { @RequestParam(required = false) String status, @RequestParam(required = false) String level, @RequestParam(required = false) String category, + @RequestParam(required = false) String vesselMmsi, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { return eventService.getEvents( - status, level, category, + status, level, category, vesselMmsi, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "occurredAt")) ); } diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/EventService.java b/backend/src/main/java/gc/mda/kcg/domain/event/EventService.java index 69dd17d..9137486 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/event/EventService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/event/EventService.java @@ -32,7 +32,7 @@ public class EventService { * 이벤트 목록 조회 (필터 조합). */ @Transactional(readOnly = true) - public Page getEvents(String status, String level, String category, Pageable pageable) { + public Page getEvents(String status, String level, String category, String vesselMmsi, Pageable pageable) { Specification spec = Specification.where(null); if (status != null && !status.isBlank()) { @@ -44,6 +44,9 @@ public class EventService { if (category != null && !category.isBlank()) { spec = spec.and((root, query, cb) -> cb.equal(root.get("category"), category)); } + if (vesselMmsi != null && !vesselMmsi.isBlank()) { + spec = spec.and((root, query, cb) -> cb.equal(root.get("vesselMmsi"), vesselMmsi)); + } // 기본 정렬: occurredAt DESC return eventRepository.findAll(spec, pageable); diff --git a/frontend/src/features/surveillance/LiveMapView.tsx b/frontend/src/features/surveillance/LiveMapView.tsx index 450dfb3..69103aa 100644 --- a/frontend/src/features/surveillance/LiveMapView.tsx +++ b/frontend/src/features/surveillance/LiveMapView.tsx @@ -4,9 +4,23 @@ import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLay import type { MarkerData } from '@lib/map'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; -import { AlertTriangle, Ship, Radio, Layers, Zap, Activity, Clock, Pin } from 'lucide-react'; -import { useVesselStore } from '@stores/vesselStore'; -import { useEventStore } from '@stores/eventStore'; +import { AlertTriangle, Ship, Radio, Zap, Activity, Clock, Pin, Loader2, WifiOff } from 'lucide-react'; +import { + fetchVesselAnalysis, + type VesselAnalysisItem, +} from '@/services/vesselAnalysisApi'; +import { + getEvents, + type PredictionEvent, +} from '@/services/event'; + +// ─── 위험도 레벨 → 마커 색상 ───────────────── +const RISK_MARKER_COLOR: Record = { + CRITICAL: '#ef4444', + HIGH: '#f97316', + MEDIUM: '#3b82f6', + LOW: '#6b7280', +}; interface MapEvent { id: string; @@ -18,9 +32,9 @@ interface MapEvent { risk: number; lat: number; lng: number; + level: string; } - const EVENT_COLORS: Record = { 'EEZ 침범': '#ef4444', '다크베셀': '#f97316', @@ -33,6 +47,8 @@ const eventIconMap: Record = { 'AIS 신호 소실': Radio, }; +const MAX_VESSEL_MARKERS = 100; + function RiskBar({ value, size = 'md' }: { value: number; size?: 'sm' | 'md' }) { const pct = value * 100; const h = size === 'sm' ? 'h-1' : 'h-1.5'; @@ -47,42 +63,94 @@ function RiskBar({ value, size = 'md' }: { value: number; size?: 'sm' | 'md' }) } export function LiveMapView() { - const { vessels, loaded: vesselsLoaded, load: loadVessels } = useVesselStore(); - const { events: storeEvents, loaded: eventsLoaded, load: loadEvents } = useEventStore(); + // 실데이터 상태 + const [vesselItems, setVesselItems] = useState([]); + const [activeEvents, setActiveEvents] = useState([]); + const [serviceAvailable, setServiceAvailable] = useState(true); + const [loading, setLoading] = useState(true); - useEffect(() => { if (!vesselsLoaded) loadVessels(); }, [vesselsLoaded, loadVessels]); - useEffect(() => { if (!eventsLoaded) loadEvents(); }, [eventsLoaded, loadEvents]); + // 데이터 로드 + useEffect(() => { + let cancelled = false; - // Map store events (first 3) into local MapEvent shape + const loadData = async () => { + setLoading(true); + try { + const [analysisRes, eventsRes] = await Promise.all([ + fetchVesselAnalysis().catch(() => null), + getEvents({ status: 'NEW,ACK,IN_PROGRESS', size: 10 }).catch(() => null), + ]); + + if (cancelled) return; + + if (analysisRes) { + setServiceAvailable(analysisRes.serviceAvailable); + // riskScore 내림차순 정렬, 최대 100건 + const sorted = [...analysisRes.items].sort( + (a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score, + ); + setVesselItems(sorted.slice(0, MAX_VESSEL_MARKERS)); + } else { + setServiceAvailable(false); + } + + setActiveEvents(eventsRes?.content ?? []); + } catch { + setServiceAvailable(false); + } finally { + if (!cancelled) setLoading(false); + } + }; + + loadData(); + return () => { cancelled = true; }; + }, []); + + // 이벤트 → MapEvent 변환 const mapEvents: MapEvent[] = useMemo( () => - storeEvents.slice(0, 3).map((e) => ({ - id: e.id, - type: e.type, - mmsi: e.mmsi ?? '미상', - nationality: e.mmsi?.startsWith('412') ? 'CN' : e.mmsi?.startsWith('440') ? 'KR' : '미상', - time: e.time.split(' ')[1] ?? e.time, - vesselName: e.vesselName ?? '미상', - risk: (e.level === 'CRITICAL' ? 0.94 : e.level === 'HIGH' ? 0.91 : 0.88), - lat: e.lat ?? 0, - lng: e.lng ?? 0, - })), - [storeEvents], + activeEvents + .filter((e) => e.lat != null && e.lon != null) + .map((e) => ({ + id: String(e.id), + type: e.category, + mmsi: e.vesselMmsi ?? '미상', + nationality: e.vesselMmsi?.startsWith('412') ? 'CN' : e.vesselMmsi?.startsWith('440') ? 'KR' : '미상', + time: e.occurredAt.includes(' ') ? e.occurredAt.split(' ')[1]?.slice(0, 5) ?? e.occurredAt : e.occurredAt, + vesselName: e.vesselName ?? '미상', + risk: e.aiConfidence ?? (e.level === 'CRITICAL' ? 0.94 : e.level === 'HIGH' ? 0.91 : 0.88), + lat: e.lat!, + lng: e.lon!, + level: e.level, + })), + [activeEvents], ); - // Map store vessels into AIS display list - const aisVessels = useMemo( - () => - vessels.map((v) => ({ - lat: v.lat, - lng: v.lng, - name: v.name, - type: v.type, - speed: v.speed != null ? `${v.speed}kt` : '미상', - heading: v.heading ?? 0, - })), - [vessels], - ); + // 선박 분석 데이터 → 마커용 변환 (좌표 없으므로 zone 기반 더미 좌표 생성) + // vessel_analysis에는 좌표가 없으므로 zone 기반 대략적 배치 + const vesselMarkers = useMemo(() => { + // zone → 대략적 좌표 매핑 + const ZONE_COORDS: Record = { + WEST_SEA: { lat: 36.5, lng: 124.5 }, + SOUTH_SEA: { lat: 33.5, lng: 127.0 }, + EAST_SEA: { lat: 37.0, lng: 130.0 }, + JEJU: { lat: 33.2, lng: 126.5 }, + NLL: { lat: 37.8, lng: 125.0 }, + }; + const DEFAULT_COORD = { lat: 35.5, lng: 126.5 }; + + return vesselItems.map((item, idx) => { + const zone = item.algorithms.location.zone; + const base = ZONE_COORDS[zone] ?? DEFAULT_COORD; + // 같은 zone 내에서 약간의 오프셋 추가 + const offset = idx * 0.03; + return { + item, + lat: base.lat + (Math.sin(idx * 2.1) * 0.8) + offset * 0.1, + lng: base.lng + (Math.cos(idx * 1.7) * 1.2) + offset * 0.1, + }; + }); + }, [vesselItems]); const [selectedEvent, setSelectedEvent] = useState(null); const mapRef = useRef(null); @@ -95,28 +163,28 @@ export function LiveMapView() { } }, [mapEvents, selectedEvent]); - // deck.gl 레이어: 선택 이벤트에 따라 마커 크기 변경 + // deck.gl 레이어 const buildLayers = useCallback(() => [ ...STATIC_LAYERS, - // 일반 AIS 선박 + // 선박 분석 데이터 마커 (riskLevel 기반 색상) createMarkerLayer( 'ais-vessels', - aisVessels.map((v): MarkerData => { - const isPatrol = v.type === '경비함' || v.type === '순찰선'; - const isKorean = v.type === '한국어선'; + vesselMarkers.map((v): MarkerData => { + const level = v.item.algorithms.riskScore.level; + const color = RISK_MARKER_COLOR[level] ?? '#6b7280'; return { lat: v.lat, lng: v.lng, - color: isPatrol ? '#a855f7' : isKorean ? '#3b82f6' : '#64748b', - radius: isPatrol ? 900 : 600, - label: v.name, + color, + radius: level === 'CRITICAL' ? 900 : level === 'HIGH' ? 750 : 600, + label: v.item.mmsi, }; }), ), // 이벤트 경보 반경 원 createRadiusLayer( 'alert-radius', - mapEvents.map(evt => ({ + mapEvents.map((evt) => ({ lat: evt.lat, lng: evt.lng, radius: 8000, @@ -134,11 +202,11 @@ export function LiveMapView() { radius: evt.id === selectedEvent?.id ? 1600 : 1100, })), ), - ], [selectedEvent, mapEvents, aisVessels]); + ], [selectedEvent, mapEvents, vesselMarkers]); - useMapLayers(mapRef, buildLayers, [selectedEvent, mapEvents, aisVessels]); + useMapLayers(mapRef, buildLayers, [selectedEvent, mapEvents, vesselMarkers]); - // deck.gl onClick → 이벤트 선택 + // deck.gl onClick const handleMapClick = useCallback((info: unknown) => { const pickInfo = info as { layer?: { id: string }; index?: number }; if (pickInfo.layer?.id === 'event-markers' && pickInfo.index != null) { @@ -150,7 +218,6 @@ export function LiveMapView() { // 지도 인스턴스 접근 (flyTo용) const handleMapReady = useCallback((map: maplibregl.Map) => { mapInstanceRef.current = map; - // 초기 선택 이벤트로 포커스 const first = mapEvents[0]; if (first) { map.flyTo({ center: [first.lng, first.lat], zoom: 9, speed: 0.6 }); @@ -175,6 +242,24 @@ export function LiveMapView() {

실시간 이벤트

현재 진행 중인 의심 활동

+ + {loading && ( +
+ + 로드 중... +
+ )} + + {!serviceAvailable && !loading && ( +
+
+ + 분석 서비스 오프라인 +
+

이벤트 데이터만 표시됩니다.

+
+ )} +
{mapEvents.map((evt) => { const IconComp = eventIconMap[evt.type] || AlertTriangle; @@ -201,6 +286,9 @@ export function LiveMapView() {
); })} + {!loading && mapEvents.length === 0 && ( +
활성 이벤트가 없습니다.
+ )} @@ -212,13 +300,11 @@ export function LiveMapView() {
선박 범례
{[ - { color: '#ef4444', label: 'EEZ 침범' }, - { color: '#f97316', label: '다크베셀' }, - { color: '#eab308', label: 'AIS 소실' }, - { color: '#a855f7', label: '경비함정' }, - { color: '#3b82f6', label: '한국어선' }, - { color: '#64748b', label: '중국어선' }, - ].map(l => ( + { color: '#ef4444', label: 'CRITICAL' }, + { color: '#f97316', label: 'HIGH' }, + { color: '#3b82f6', label: 'MEDIUM' }, + { color: '#6b7280', label: 'LOW' }, + ].map((l) => (
{l.label} @@ -234,7 +320,7 @@ export function LiveMapView() {
LIVE - 경보 {mapEvents.length}건 · AIS {aisVessels.length}척 + 경보 {mapEvents.length}건 · 분석 {vesselItems.length}척
@@ -287,23 +373,23 @@ export function LiveMapView() {
- EEZ 진입 침범 + {selectedEvent.type}
-
침투깊이: 13.5nm 침범 / 기준: 0km (정원 경계)
+
선박: {selectedEvent.vesselName} ({selectedEvent.mmsi})
- 저속 운항 지속 + 위치 정보
-
관측값: 42분 / 기준: > 30분
+
좌표: {selectedEvent.lat.toFixed(4)}, {selectedEvent.lng.toFixed(4)}
- 야간 활동 + 발생 시각
-
관측값: 02:00-05:00 / 기준: 야간 조업 의심
+
{selectedEvent.time}

이 판단 근거는 AI 모델 분석 결과이며, 최종 판단은 관리자가 수행합니다.

diff --git a/frontend/src/features/vessel/VesselDetail.tsx b/frontend/src/features/vessel/VesselDetail.tsx index f8a19bc..dff4ee9 100644 --- a/frontend/src/features/vessel/VesselDetail.tsx +++ b/frontend/src/features/vessel/VesselDetail.tsx @@ -1,97 +1,58 @@ -import { useState, useRef, useCallback } from 'react'; -import { Card, CardContent } from '@shared/components/ui/card'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; import { Badge } from '@shared/components/ui/badge'; import { - Search, ChevronDown, ChevronUp, ChevronRight, Plus, X, - Ship, AlertTriangle, Radar, Anchor, MapPin, Printer, - Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain + Search, + Ship, AlertTriangle, Radar, MapPin, Printer, + Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain, + Loader2, WifiOff, ShieldAlert, } from 'lucide-react'; -import { BaseMap, STATIC_LAYERS, createMarkerLayer, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map'; -import type { MarkerData } from '@lib/map'; +import { BaseMap, STATIC_LAYERS, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map'; +import { + fetchVesselAnalysis, + type VesselAnalysisItem, +} from '@/services/vesselAnalysisApi'; +import { getEvents, type PredictionEvent } from '@/services/event'; -// TODO: 향후 store 통합 시 교체 — VesselDetail의 VesselTrack 형상(callSign, source, detail 등)이 -// useVesselStore().vessels(VesselData)와 구조가 달라 현재는 인라인 데이터 유지 -// ─── 선박 데이터 ────────────────────── -interface VesselTrack { - id: string; +// ─── 허가 정보 타입 ────────────────────── +interface VesselPermitData { mmsi: string; - callSign: string; - source: string; - name: string; - type: string; - country: string; - detail: Record; + vesselName: string | null; + vesselNameCn: string | null; + flagCountry: string | null; + vesselType: string | null; + tonnage: number | null; + lengthM: number | null; + buildYear: number | null; + permitStatus: string | null; + permitNo: string | null; + permittedGearCodes: string[] | null; + permittedZones: string[] | null; + permitValidFrom: string | null; + permitValidTo: string | null; } -const VESSELS: VesselTrack[] = [ - { - id: '1', mmsi: '440162980', callSign: '122@', source: 'AIS', - name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', - detail: { - '청코드': '부산', '호출부호': '951554', '입항횟수': '006', '전송구분': '최종', - '선명': '태평양호', '선박종류': '어선', '총톤수': '30', '국제톤수': '30', - '입항일시': '2023-03-28 16:00', '계선장소': '기타 남항 사설조선소', - '전출항지': '2023-03-28 16:00', '전출항지항구명': '김천', '위험물톤수': '-', - '외내항구분': '내항', '입항수리일자': '2023-03-24', - '한국인선원수': '5', '외국인선원수': '9', '예선': 'N', '도선': 'N', - }, - }, - { - id: '2', mmsi: '440162923', callSign: '122@', source: 'AIS', - name: 'ZZ', type: 'V-Pass', country: 'Korea(Republic of)', - detail: { - '청코드': '인천', '호출부호': '862331', '입항횟수': '012', '전송구분': '최종', - '선명': '금강호', '선박종류': '어선', '총톤수': '45', '국제톤수': '45', - '입항일시': '2023-04-15 09:00', '계선장소': '인천항 제2부두', - '전출항지': '2023-04-15 09:00', '전출항지항구명': '인천', '위험물톤수': '-', - '외내항구분': '내항', '입항수리일자': '2023-04-10', - '한국인선원수': '3', '외국인선원수': '7', '예선': 'N', '도선': 'N', - }, - }, -]; +const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; -// ─── 특이운항 / 비허가 선박 ────────────── -const ALERT_VESSELS = [ - { name: '제303 대양호', highlight: true }, - { name: '제609 한일호', highlight: false }, - { name: '한진아일랜드 고속훼리', highlight: false }, -]; - -// ─── AI 조업 분석 데이터 ───────────────── -interface FishingAnalysis { - no: number; - mmsi: string; - name: string; - eezPermit: '허가' | '무허가'; - vesselType: '어선' | '어구'; - gearType: string; - gearIcon: string; +async function fetchVesselPermit(mmsi: string): Promise { + try { + const res = await fetch(`${API_BASE}/vessel-permits/${encodeURIComponent(mmsi)}`, { + credentials: 'include', + }); + if (!res.ok) return null; + return res.json(); + } catch { + return null; + } } -const FISHING_ANALYSIS: FishingAnalysis[] = [ - { no: 1, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '무허가', vesselType: '어구', gearType: '쌍끌이', gearIcon: '🚢' }, - { no: 2, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '범장망', gearIcon: '🚢' }, - { no: 3, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' }, - { no: 4, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' }, - { no: 5, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' }, -]; - -const GEAR_FILTERS = ['외끌이', '쌍끌이', '트롤', '범장망', '형망', '채낚기', '통망']; - -// ─── 지도 마커 ──────────────────────── -const MAP_MARKERS = [ - { id: 'm1', x: 72, y: 38, label: '현재선박명', sensors: ['E', 'A', 'V'] }, - { id: 'm2', x: 65, y: 43, label: '현재선박명', sensors: ['V', 'B', 'A'] }, - { id: 'm3', x: 73, y: 49, label: '현재선박명', sensors: ['A', 'V', 'E'] }, -]; -const VTS_MARKERS = [{ id: 'vts1', x: 52, y: 63, label: '태안연안', sub: 'VTS 신호수신 선박명' }]; -const PATROL_MARKERS = [ - { id: 'p1', x: 62, y: 63, label: 'E204', sub: '함정레이더 신호수신 선박명' }, - { id: 'p2', x: 80, y: 70, label: 'E204', sub: '함정레이더 신호수신 선박명' }, -]; -const CLUSTERS = [ - { x: 58, y: 22, n: 10 }, { x: 75, y: 30, n: 5 }, { x: 52, y: 55, n: 5 }, { x: 35, y: 68, n: 5 }, -]; +// ─── 위험도 레벨 → 색상 매핑 ────────────── +const RISK_LEVEL_CONFIG: Record = { + CRITICAL: { label: '심각', color: 'text-red-400', bg: 'bg-red-500/15' }, + HIGH: { label: '높음', color: 'text-orange-400', bg: 'bg-orange-500/15' }, + MEDIUM: { label: '보통', color: 'text-yellow-400', bg: 'bg-yellow-500/15' }, + LOW: { label: '낮음', color: 'text-blue-400', bg: 'bg-blue-500/15' }, +}; const RIGHT_TOOLS = [ { icon: Crosshair, label: '구역설정' }, { icon: Ruler, label: '거리' }, @@ -102,248 +63,328 @@ const RIGHT_TOOLS = [ // ─── 메인 컴포넌트 ──────────────────── export function VesselDetail() { - const [expandedId, setExpandedId] = useState('2'); - const [startDate, setStartDate] = useState('2023-08-20 11:30:02'); - const [endDate, setEndDate] = useState('2023-08-20 11:30:02'); - const [shipId, setShipId] = useState(''); - const [showAiPanel, setShowAiPanel] = useState(false); - const [gearChecks, setGearChecks] = useState>({ '쌍끌이': true, '범장망': true }); + const { id: mmsiParam } = useParams<{ id: string }>(); + + // 데이터 상태 + const [vessel, setVessel] = useState(null); + const [permit, setPermit] = useState(null); + const [events, setEvents] = useState([]); + const [serviceAvailable, setServiceAvailable] = useState(true); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 검색 상태 (검색 패널용) + const [searchMmsi, setSearchMmsi] = useState(mmsiParam ?? ''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const mapRef = useRef(null); - const buildLayers = useCallback(() => [ - ...STATIC_LAYERS, + // 데이터 로드 + useEffect(() => { + if (!mmsiParam) { + setLoading(false); + setError('MMSI 파라미터가 필요합니다.'); + return; + } - // 관할해역 구역 - createZoneLayer('jurisdiction', JURISDICTION_AREAS.map(a => ({ - name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000, - })), 80000, 0.05), + let cancelled = false; - // 등심선 - ...DEPTH_CONTOURS.map((contour, i) => - createPolylineLayer(`depth-${i}`, contour.points as [number, number][], { - color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4], - }) - ), + const loadData = async () => { + setLoading(true); + setError(null); - // 선박 마커 - createMarkerLayer('vessels', MAP_MARKERS.map((m): MarkerData => { - const lat = 34.2 + Math.random() * 2; - const lng = 125.5 + Math.random() * 3; - return { lat, lng, color: '#3b82f6', radius: 800, label: m.label }; - })), + try { + const [analysisRes, permitRes, eventsRes] = await Promise.all([ + fetchVesselAnalysis().catch(() => null), + fetchVesselPermit(mmsiParam), + getEvents({ vesselMmsi: mmsiParam, size: 10 }).catch(() => null), + ]); - // VTS 마커 - createMarkerLayer('vts', VTS_MARKERS.map((m): MarkerData => ({ - lat: 34.0, lng: 126.2, color: '#eab308', radius: 800, label: m.label, - }))), + if (cancelled) return; - // 함정 마커 - createMarkerLayer('patrols', PATROL_MARKERS.map((m): MarkerData => ({ - lat: 33.5 + Math.random(), lng: 127.0 + Math.random(), color: '#a855f7', radius: 800, label: m.label, - }))), + if (!analysisRes) { + setServiceAvailable(false); + setPermit(permitRes); + setEvents(eventsRes?.content ?? []); + setLoading(false); + return; + } - // 클러스터 - createMarkerLayer('clusters', CLUSTERS.map((c, i): MarkerData => ({ - lat: 33.0 + i * 0.8, lng: 125.5 + i * 0.5, color: '#6b7280', radius: 2400, label: `${c.n}척`, - }))), + setServiceAvailable(analysisRes.serviceAvailable); + const found = analysisRes.items.find((item) => item.mmsi === mmsiParam) ?? null; + setVessel(found); + setPermit(permitRes); + setEvents(eventsRes?.content ?? []); + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : '데이터 로드 실패'); + } + } finally { + if (!cancelled) setLoading(false); + } + }; - // 선박충돌 알림 - createMarkerLayer('alerts', [{ - lat: 33.8, lng: 127.5, color: '#ef4444', radius: 1400, label: '선박충돌', - }]), - ], []); + loadData(); + return () => { cancelled = true; }; + }, [mmsiParam]); + + // 지도 레이어 + const buildLayers = useCallback(() => { + const layers = [ + ...STATIC_LAYERS, + createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({ + name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000, + })), 80000, 0.05), + ...DEPTH_CONTOURS.map((contour, i) => + createPolylineLayer(`depth-${i}`, contour.points as [number, number][], { + color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4], + }) + ), + ]; + + // 선박 위치가 없으므로 분석 데이터의 zone 기반으로 대략적 위치 표시는 불가 + // vessel-analysis에는 좌표가 없으므로 마커 생략 + + return layers; + }, []); useMapLayers(mapRef, buildLayers, []); - const toggleGear = (g: string) => setGearChecks((p) => ({ ...p, [g]: !p[g] })); + // 위험도 점수 바 + const riskScore = vessel?.algorithms.riskScore.score ?? 0; + const riskLevel = vessel?.algorithms.riskScore.level ?? 'LOW'; + const riskConfig = RISK_LEVEL_CONFIG[riskLevel] ?? RISK_LEVEL_CONFIG.LOW; return (
- {/* ── 좌측: 항적조회 패널 ── */} + {/* ── 좌측: 선박 정보 패널 ── */}
{/* 헤더: 검색 조건 */}
-

항적조회

+

선박 상세 조회

시작/종료 setStartDate(e.target.value)} - className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50" /> + className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50" + placeholder="YYYY-MM-DD HH:mm" /> ~ setEndDate(e.target.value)} - className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50" /> + className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50" + placeholder="YYYY-MM-DD HH:mm" />
- 조회간격 - - 선박ID - setShipId(e.target.value)} + MMSI + setSearchMmsi(e.target.value)} + placeholder="MMSI 입력" className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none" /> -
-
-
- {/* 선박 카드 */} -
- {VESSELS.map((v) => { - const isOpen = expandedId === v.id; - return ( -
-
setExpandedId(isOpen ? null : v.id)}> -
-
- ID | {v.mmsi} - 호출부호 | {v.callSign} - 출처 | {v.source} -
-
- {v.name} - {v.type} -
-
🇰🇷 {v.country}
-
- {isOpen ? : } -
+ {/* 로딩/에러 상태 */} + {loading && ( +
+ + 데이터 로드 중... +
+ )} - {isOpen && ( -
-
- {Object.entries(v.detail).map(([k, val], i) => ( -
- {k} - {val} -
- ))} -
-
- )} -
- ); - })} -
-
+ {error && !loading && ( +
+ + {error} +
+ )} - {/* ── 중앙: 지도 ── */} -
- - {/* 상단 패널: 특이운항 + 비허가/재제선박 */} -
- {(['특이운항', '비허가/재제선박'] as const).map((title) => ( -
-
- {title} - -
- {ALERT_VESSELS.map((v, i) => ( - - ))} + {!serviceAvailable && !loading && !error && ( +
+
+ + 분석 서비스 오프라인
- ))} -
+

iran 백엔드가 연결되지 않아 분석 데이터를 표시할 수 없습니다.

+
+ )} - {/* AI 조업 분석 패널 (토글) */} - - - {showAiPanel && ( -
- {/* 헤더 */} -
-
- - AI 조업 분석 + {/* 선박 정보 */} + {!loading && !error && ( +
+ {/* 기본 정보 카드 */} +
+
+ + 기본 정보
- -
- - {/* 선택선박 + 조업식별 필터 */} -
-
-
- - 선택선박 - 50 - -
- -
-
- 조업식별 - {GEAR_FILTERS.map((g) => ( - +
+ {[ + ['MMSI', mmsiParam ?? '-'], + ['선박 유형', vessel?.classification.vesselType ?? permit?.vesselType ?? '-'], + ['국적', permit?.flagCountry ?? '-'], + ['선명', permit?.vesselName ?? '-'], + ['선명(중문)', permit?.vesselNameCn ?? '-'], + ['톤수', permit?.tonnage != null ? `${permit.tonnage}톤` : '-'], + ['길이', permit?.lengthM != null ? `${permit.lengthM}m` : '-'], + ['건조년도', permit?.buildYear != null ? String(permit.buildYear) : '-'], + ['구역', vessel?.algorithms.location.zone ?? '-'], + ['기선거리', vessel?.algorithms.location.distToBaselineNm != null + ? `${vessel.algorithms.location.distToBaselineNm.toFixed(1)}nm` : '-'], + ['시즌', vessel?.classification.season ?? '-'], + ].map(([k, v], i) => ( +
+ {k} + {v} +
))}
- {/* 테이블 헤더 */} -
- 구분 - 선박ID/선박명 - EEZ허가 - 어선/어구 - 조업식별 -
+ {/* 허가 정보 */} + {permit && ( +
+
+ + 허가 정보 +
+
+ {[ + ['허가 상태', permit.permitStatus ?? '-'], + ['허가 번호', permit.permitNo ?? '-'], + ['허가 기간', permit.permitValidFrom && permit.permitValidTo + ? `${permit.permitValidFrom} ~ ${permit.permitValidTo}` : '-'], + ['허용 어구', permit.permittedGearCodes?.join(', ') || '-'], + ['허용 구역', permit.permittedZones?.join(', ') || '-'], + ].map(([k, v], i) => ( +
+ {k} + {v} +
+ ))} +
+
+ )} - {/* 테이블 행 */} -
- {FISHING_ANALYSIS.map((row) => ( -
- {row.no} -
-
ID | {row.mmsi}
-
{row.name}
+ {/* AI 분석 결과 */} + {vessel && ( +
+
+ + AI 분석 결과 +
+ + {/* 위험도 점수 */} +
+
+ 위험도 + + {riskConfig.label} +
- - {row.eezPermit} - - {row.vesselType} -
- {row.gearType !== '-' && ( - - {row.gearIcon && {row.gearIcon}} - {row.gearType} - - )} - {row.gearType === '-' && -} +
+ + {Math.round(riskScore * 100)} + + /100 +
+
+
- ))} + + {/* 알고리즘 상세 */} +
+ {[ + ['활동 상태', vessel.algorithms.activity.state], + ['UCAF 점수', vessel.algorithms.activity.ucafScore.toFixed(2)], + ['UCFT 점수', vessel.algorithms.activity.ucftScore.toFixed(2)], + ['다크베셀', vessel.algorithms.darkVessel.isDark ? '예 (의심)' : '아니오'], + ['AIS 공백', vessel.algorithms.darkVessel.gapDurationMin > 0 + ? `${vessel.algorithms.darkVessel.gapDurationMin}분` : '-'], + ['스푸핑 점수', vessel.algorithms.gpsSpoofing.spoofingScore.toFixed(2)], + ['BD09 오프셋', `${vessel.algorithms.gpsSpoofing.bd09OffsetM.toFixed(0)}m`], + ['속도 점프', `${vessel.algorithms.gpsSpoofing.speedJumpCount}회`], + ['클러스터', `#${vessel.algorithms.cluster.clusterId} (${vessel.algorithms.cluster.clusterSize}척)`], + ['선단 역할', vessel.algorithms.fleetRole.role], + ['환적 의심', vessel.algorithms.transship.isSuspect ? '예' : '아니오'], + ['환적 상대', vessel.algorithms.transship.pairMmsi || '-'], + ['환적 시간', vessel.algorithms.transship.durationMin > 0 + ? `${vessel.algorithms.transship.durationMin}분` : '-'], + ].map(([k, v], i) => ( +
+ {k} + {v} +
+ ))} +
+
+ )} + + {/* 관련 이벤트 이력 */} +
+
+ + 관련 이벤트 이력 + {events.length}건 +
+ {events.length === 0 ? ( +
관련 이벤트가 없습니다.
+ ) : ( +
+ {events.map((evt) => { + const lvl = RISK_LEVEL_CONFIG[evt.level] ?? RISK_LEVEL_CONFIG.LOW; + return ( +
+
+ + {evt.level} + + {evt.title} + + {evt.status} + +
+
+ {evt.occurredAt} {evt.areaName ? `| ${evt.areaName}` : ''} +
+ {evt.detail && ( +
{evt.detail}
+ )} +
+ ); + })} +
+ )} +
+
+ )} +
+ + {/* ── 중앙: 지도 ── */} +
+ {/* MMSI 표시 */} + {mmsiParam && ( +
+
+ + MMSI: {mmsiParam} + {vessel && ( + + 위험도: {riskConfig.label} + + )}
)} - {/* MapLibre GL + deck.gl 지도 */} UTC - 2023-07-10(월) 12:32:45 + {new Date().toISOString().substring(0, 19).replace('T', ' ')} - 8,531 | 0 25 50NM
diff --git a/frontend/src/services/event.ts b/frontend/src/services/event.ts index a43de18..a406b8f 100644 --- a/frontend/src/services/event.ts +++ b/frontend/src/services/event.ts @@ -50,6 +50,7 @@ export async function getEvents(params?: { status?: string; level?: string; category?: string; + vesselMmsi?: string; page?: number; size?: number; }): Promise { @@ -57,6 +58,7 @@ export async function getEvents(params?: { if (params?.status) query.set('status', params.status); if (params?.level) query.set('level', params.level); if (params?.category) query.set('category', params.category); + if (params?.vesselMmsi) query.set('vesselMmsi', params.vesselMmsi); query.set('page', String(params?.page ?? 0)); query.set('size', String(params?.size ?? 20)); const res = await fetch(`${API_BASE}/events?${query}`, { credentials: 'include' });