feat: VesselDetail + LiveMapView 실데이터 전환

VesselDetail: 인라인 mock → fetchVesselAnalysis() + vessel-permits API
  - MMSI 기반 선박 분석 데이터 + 허가 정보 + 관련 이벤트 이력
  - 알고리즘 결과 전체 표시 (risk/dark/spoofing/transship/fleet)

LiveMapView: vesselStore mock → fetchVesselAnalysis() + getEvents()
  - 위험도 TOP 100 선박 마커 (riskLevel별 색상)
  - 활성 이벤트 오버레이

EventController에 vesselMmsi 필터 파라미터 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-07 13:29:43 +09:00
부모 cc1b1e20df
커밋 6ac9184016
5개의 변경된 파일473개의 추가작업 그리고 341개의 파일을 삭제

파일 보기

@ -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"))
);
}

파일 보기

@ -32,7 +32,7 @@ public class EventService {
* 이벤트 목록 조회 (필터 조합).
*/
@Transactional(readOnly = true)
public Page<PredictionEvent> getEvents(String status, String level, String category, Pageable pageable) {
public Page<PredictionEvent> getEvents(String status, String level, String category, String vesselMmsi, Pageable pageable) {
Specification<PredictionEvent> 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);

파일 보기

@ -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<string, string> = {
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<string, string> = {
'EEZ 침범': '#ef4444',
'다크베셀': '#f97316',
@ -33,6 +47,8 @@ const eventIconMap: Record<string, typeof AlertTriangle> = {
'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<VesselAnalysisItem[]>([]);
const [activeEvents, setActiveEvents] = useState<PredictionEvent[]>([]);
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<string, { lat: number; lng: number }> = {
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<MapEvent | null>(null);
const mapRef = useRef<MapHandle>(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() {
<h2 className="text-lg font-bold text-heading"> </h2>
<p className="text-[11px] text-hint mt-0.5"> </p>
</div>
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
<span className="ml-2 text-[11px] text-hint"> ...</span>
</div>
)}
{!serviceAvailable && !loading && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-center gap-2">
<WifiOff className="w-4 h-4 text-yellow-400" />
<span className="text-[11px] text-yellow-400 font-medium"> </span>
</div>
<p className="text-[10px] text-hint mt-1"> .</p>
</div>
)}
<div className="space-y-2">
{mapEvents.map((evt) => {
const IconComp = eventIconMap[evt.type] || AlertTriangle;
@ -201,6 +286,9 @@ export function LiveMapView() {
</div>
);
})}
{!loading && mapEvents.length === 0 && (
<div className="text-[11px] text-hint text-center py-4"> .</div>
)}
</div>
</div>
@ -212,13 +300,11 @@ export function LiveMapView() {
<div className="text-[9px] text-muted-foreground font-bold mb-1"> </div>
<div className="space-y-0.5">
{[
{ 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) => (
<div key={l.label} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: l.color }} />
<span className="text-[8px] text-muted-foreground">{l.label}</span>
@ -234,7 +320,7 @@ export function LiveMapView() {
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] text-red-400 font-bold">LIVE</span>
<span className="text-[9px] text-hint"> {mapEvents.length} · AIS {aisVessels.length}</span>
<span className="text-[9px] text-hint"> {mapEvents.length} · {vesselItems.length}</span>
</div>
</div>
</div>
@ -287,23 +373,23 @@ export function LiveMapView() {
<div className="border-l-2 border-red-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<AlertTriangle className="w-3 h-3 text-red-400" />
<span className="text-red-400 font-medium">EEZ </span>
<span className="text-red-400 font-medium">{selectedEvent.type}</span>
</div>
<div className="text-[10px] text-hint mt-0.5">침투깊이: 13.5nm / 기준: 0km ( )</div>
<div className="text-[10px] text-hint mt-0.5">: {selectedEvent.vesselName} ({selectedEvent.mmsi})</div>
</div>
<div className="border-l-2 border-orange-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<Activity className="w-3 h-3 text-orange-400" />
<span className="text-orange-400 font-medium"> </span>
<span className="text-orange-400 font-medium"> </span>
</div>
<div className="text-[10px] text-hint mt-0.5">관측값: 42분 / : &gt; 30</div>
<div className="text-[10px] text-hint mt-0.5">: {selectedEvent.lat.toFixed(4)}, {selectedEvent.lng.toFixed(4)}</div>
</div>
<div className="border-l-2 border-green-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<Clock className="w-3 h-3 text-green-400" />
<span className="text-green-400 font-medium"> </span>
<span className="text-green-400 font-medium"> </span>
</div>
<div className="text-[10px] text-hint mt-0.5">관측값: 02:00-05:00 / 기준: 야간 </div>
<div className="text-[10px] text-hint mt-0.5">{selectedEvent.time}</div>
</div>
</div>
<p className="text-[10px] text-hint mt-3"> AI , .</p>

파일 보기

@ -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<string, string>;
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<VesselPermitData | null> {
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<string, { label: string; color: string; bg: string }> = {
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<string | null>('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<Record<string, boolean>>({ '쌍끌이': true, '범장망': true });
const { id: mmsiParam } = useParams<{ id: string }>();
// 데이터 상태
const [vessel, setVessel] = useState<VesselAnalysisItem | null>(null);
const [permit, setPermit] = useState<VesselPermitData | null>(null);
const [events, setEvents] = useState<PredictionEvent[]>([]);
const [serviceAvailable, setServiceAvailable] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 검색 상태 (검색 패널용)
const [searchMmsi, setSearchMmsi] = useState(mmsiParam ?? '');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const mapRef = useRef<MapHandle>(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 (
<div className="flex h-[calc(100vh-7.5rem)] gap-0 -m-4">
{/* ── 좌측: 항적조회 패널 ── */}
{/* ── 좌측: 선박 정보 패널 ── */}
<div className="w-[370px] shrink-0 bg-card border-r border-border flex flex-col overflow-hidden">
{/* 헤더: 검색 조건 */}
<div className="p-3 border-b border-border space-y-2">
<h2 className="text-sm font-bold text-heading"></h2>
<h2 className="text-sm font-bold text-heading"> </h2>
<div className="flex items-center gap-1">
<span className="text-[9px] text-hint w-14 shrink-0">/</span>
<input value={startDate} onChange={(e) => 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" />
<span className="text-hint text-[10px]">~</span>
<input value={endDate} onChange={(e) => 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" />
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] text-hint w-14 shrink-0"></span>
<select className="bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none w-16">
<option></option><option>1</option><option>5</option><option>10</option><option>30</option>
</select>
<span className="text-[9px] text-hint ml-2 shrink-0">ID</span>
<input value={shipId} onChange={(e) => setShipId(e.target.value)}
<span className="text-[9px] text-hint w-14 shrink-0">MMSI</span>
<input value={searchMmsi} onChange={(e) => 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" />
</div>
<div className="flex items-center justify-between">
<button className="flex items-center gap-1 text-[10px] text-blue-400 hover:text-blue-300">
<Plus className="w-3 h-3" />
</button>
<button className="flex items-center gap-1.5 bg-secondary border border-slate-700/50 rounded px-3 py-1 text-[10px] text-label hover:bg-switch-background transition-colors">
<Search className="w-3 h-3" />
</button>
</div>
</div>
{/* 선박 카드 */}
<div className="flex-1 overflow-y-auto">
{VESSELS.map((v) => {
const isOpen = expandedId === v.id;
return (
<div key={v.id} className="border-b border-border">
<div className="flex items-center gap-2 px-3 py-2.5 hover:bg-surface-overlay cursor-pointer" onClick={() => setExpandedId(isOpen ? null : v.id)}>
<div className="flex-1 min-w-0">
<div className="text-[9px] text-hint">
ID | <span className="text-label">{v.mmsi}</span>
<span className="ml-2"> | <span className="text-label">{v.callSign}</span></span>
<span className="ml-2"> | <span className="text-label">{v.source}</span></span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[11px] font-bold text-heading">{v.name}</span>
<Badge className={`text-[7px] px-1 py-0 border-0 ${v.type === 'Fishing' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'}`}>{v.type}</Badge>
</div>
<div className="text-[8px] text-hint mt-0.5 flex items-center gap-1">🇰🇷 {v.country}</div>
</div>
{isOpen ? <ChevronUp className="w-4 h-4 text-blue-400" /> : <ChevronDown className="w-4 h-4 text-hint" />}
</div>
{/* 로딩/에러 상태 */}
{loading && (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-6 h-6 text-blue-400 animate-spin" />
<span className="ml-2 text-sm text-hint"> ...</span>
</div>
)}
{isOpen && (
<div className="px-3 pb-3">
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
{Object.entries(v.detail).map(([k, val], i) => (
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
<span className="flex-1 px-2.5 py-1.5 text-label">{val}</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
{error && !loading && (
<div className="flex-1 flex flex-col items-center justify-center gap-2 px-4">
<AlertTriangle className="w-8 h-8 text-red-400" />
<span className="text-sm text-red-400 text-center">{error}</span>
</div>
)}
{/* ── 중앙: 지도 ── */}
<div className="flex-1 relative bg-card/40 overflow-hidden">
{/* 상단 패널: 특이운항 + 비허가/재제선박 */}
<div className="absolute top-3 left-3 z-10 flex gap-2">
{(['특이운항', '비허가/재제선박'] as const).map((title) => (
<div key={title} className="bg-card/95 backdrop-blur-sm rounded-lg border border-border w-52">
<div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
<span className="text-[10px] font-bold text-heading">{title}</span>
<ChevronDown className="w-3 h-3 text-hint" />
</div>
{ALERT_VESSELS.map((v, i) => (
<button key={i} className={`w-full flex items-center justify-between px-3 py-1.5 text-[9px] transition-colors ${v.highlight ? 'bg-red-600/80 text-heading' : 'text-label hover:bg-surface-overlay'}`}>
{v.name}<ChevronRight className="w-3 h-3 opacity-50" />
</button>
))}
{!serviceAvailable && !loading && !error && (
<div className="p-3 mx-3 mt-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-center gap-2">
<WifiOff className="w-4 h-4 text-yellow-400" />
<span className="text-[11px] text-yellow-400 font-medium"> </span>
</div>
))}
</div>
<p className="text-[10px] text-hint mt-1">iran .</p>
</div>
)}
{/* AI 조업 분석 패널 (토글) */}
<button
onClick={() => setShowAiPanel(!showAiPanel)}
className="absolute top-3 right-14 z-20 flex items-center gap-1.5 bg-blue-600/90 backdrop-blur-sm text-heading rounded-lg px-3 py-1.5 text-[10px] font-bold hover:bg-blue-500 transition-colors shadow-lg"
>
<Brain className="w-3.5 h-3.5" />AI
</button>
{showAiPanel && (
<div className="absolute top-12 right-14 z-20 w-[480px] bg-input-background/98 backdrop-blur-md rounded-xl border border-blue-500/30 shadow-2xl shadow-blue-900/20 overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 bg-surface-overlay border-b border-border">
<div className="flex items-center gap-2">
<Brain className="w-4 h-4 text-blue-400" />
<span className="text-[11px] font-bold text-heading">AI </span>
{/* 선박 정보 */}
{!loading && !error && (
<div className="flex-1 overflow-y-auto">
{/* 기본 정보 카드 */}
<div className="p-3 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<Ship className="w-4 h-4 text-blue-400" />
<span className="text-[11px] font-bold text-heading"> </span>
</div>
<button onClick={() => setShowAiPanel(false)} className="text-hint hover:text-heading"><X className="w-4 h-4" /></button>
</div>
{/* 선택선박 + 조업식별 필터 */}
<div className="px-4 py-2.5 border-b border-border">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center gap-1.5 text-[10px]">
<Anchor className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground"></span>
<span className="text-heading font-bold text-sm">50</span>
<span className="text-hint"></span>
</div>
<button className="ml-auto bg-blue-600 text-heading rounded-lg px-4 py-1.5 text-[10px] font-bold flex items-center gap-1 hover:bg-blue-500 transition-colors">
<Search className="w-3 h-3" />
</button>
</div>
<div className="flex items-center gap-1 flex-wrap">
<span className="text-[9px] text-hint mr-1 flex items-center gap-1"><Radar className="w-3 h-3" /></span>
{GEAR_FILTERS.map((g) => (
<label key={g} className="flex items-center gap-1 cursor-pointer">
<input
type="checkbox" checked={!!gearChecks[g]}
onChange={() => toggleGear(g)}
className="w-3 h-3 rounded border-slate-600 bg-secondary text-blue-500 focus:ring-0 focus:ring-offset-0"
/>
<span className="text-[9px] text-label">{g}</span>
</label>
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
{[
['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) => (
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
<span className="flex-1 px-2.5 py-1.5 text-label">{v}</span>
</div>
))}
</div>
</div>
{/* 테이블 헤더 */}
<div className="grid grid-cols-[32px_1fr_70px_70px_90px] gap-1 px-4 py-1.5 text-[9px] text-hint font-medium border-b border-border bg-surface-overlay">
<span></span>
<span>ID/</span>
<span>EEZ허가</span>
<span>/</span>
<span></span>
</div>
{/* 허가 정보 */}
{permit && (
<div className="p-3 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<ShieldAlert className="w-4 h-4 text-green-400" />
<span className="text-[11px] font-bold text-heading"> </span>
</div>
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
{[
['허가 상태', permit.permitStatus ?? '-'],
['허가 번호', permit.permitNo ?? '-'],
['허가 기간', permit.permitValidFrom && permit.permitValidTo
? `${permit.permitValidFrom} ~ ${permit.permitValidTo}` : '-'],
['허용 어구', permit.permittedGearCodes?.join(', ') || '-'],
['허용 구역', permit.permittedZones?.join(', ') || '-'],
].map(([k, v], i) => (
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
<span className="flex-1 px-2.5 py-1.5 text-label">{v}</span>
</div>
))}
</div>
</div>
)}
{/* 테이블 행 */}
<div className="max-h-[300px] overflow-y-auto">
{FISHING_ANALYSIS.map((row) => (
<div
key={row.no}
className={`grid grid-cols-[32px_1fr_70px_70px_90px] gap-1 px-4 py-2.5 items-center border-b border-border hover:bg-surface-overlay cursor-pointer transition-colors ${row.no === 1 ? 'bg-surface-overlay' : ''}`}
>
<span className="text-[10px] text-muted-foreground">{row.no}</span>
<div>
<div className="text-[8px] text-hint">ID | {row.mmsi}</div>
<div className="text-[11px] font-bold text-heading">{row.name}</div>
{/* AI 분석 결과 */}
{vessel && (
<div className="p-3 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<Brain className="w-4 h-4 text-purple-400" />
<span className="text-[11px] font-bold text-heading">AI </span>
</div>
{/* 위험도 점수 */}
<div className="mb-3 p-2 bg-surface-overlay rounded border border-slate-700/20">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-hint"></span>
<Badge className={`border-0 text-[9px] ${riskConfig.bg} ${riskConfig.color}`}>
{riskConfig.label}
</Badge>
</div>
<span className={`text-[10px] font-bold ${row.eezPermit === '무허가' ? 'text-red-400' : 'text-green-400'}`}>
{row.eezPermit}
</span>
<span className="text-[10px] text-label">{row.vesselType}</span>
<div className="flex items-center gap-1">
{row.gearType !== '-' && (
<Badge className={`text-[8px] px-1.5 py-0.5 border-0 ${
row.gearType === '쌍끌이' ? 'bg-orange-500/20 text-orange-400'
: row.gearType === '범장망' ? 'bg-purple-500/20 text-purple-400'
: 'bg-muted text-muted-foreground'
}`}>
{row.gearIcon && <span className="mr-0.5">{row.gearIcon}</span>}
{row.gearType}
</Badge>
)}
{row.gearType === '-' && <span className="text-[10px] text-hint">-</span>}
<div className="flex items-baseline gap-1 mb-1">
<span className={`text-xl font-bold ${riskConfig.color}`}>
{Math.round(riskScore * 100)}
</span>
<span className="text-[10px] text-hint">/100</span>
</div>
<div className="h-1.5 bg-switch-background rounded-full overflow-hidden">
<div
className="h-1.5 bg-gradient-to-r from-red-600 to-red-400 rounded-full transition-all"
style={{ width: `${riskScore * 100}%` }}
/>
</div>
</div>
))}
{/* 알고리즘 상세 */}
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
{[
['활동 상태', 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) => (
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
<span className={`flex-1 px-2.5 py-1.5 ${
(k === '다크베셀' && v === '예 (의심)') || (k === '환적 의심' && v === '예')
? 'text-red-400 font-bold' : 'text-label'
}`}>{v}</span>
</div>
))}
</div>
</div>
)}
{/* 관련 이벤트 이력 */}
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-red-400" />
<span className="text-[11px] font-bold text-heading"> </span>
<span className="text-[9px] text-hint ml-auto">{events.length}</span>
</div>
{events.length === 0 ? (
<div className="text-[10px] text-hint text-center py-4"> .</div>
) : (
<div className="space-y-1.5">
{events.map((evt) => {
const lvl = RISK_LEVEL_CONFIG[evt.level] ?? RISK_LEVEL_CONFIG.LOW;
return (
<div key={evt.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
<div className="flex items-center gap-2 mb-0.5">
<Badge className={`border-0 text-[8px] px-1.5 py-0 ${lvl.bg} ${lvl.color}`}>
{evt.level}
</Badge>
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
<Badge className="border-0 text-[8px] bg-muted text-muted-foreground px-1.5 py-0">
{evt.status}
</Badge>
</div>
<div className="text-[9px] text-hint">
{evt.occurredAt} {evt.areaName ? `| ${evt.areaName}` : ''}
</div>
{evt.detail && (
<div className="text-[9px] text-muted-foreground mt-0.5 truncate">{evt.detail}</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
)}
</div>
{/* ── 중앙: 지도 ── */}
<div className="flex-1 relative bg-card/40 overflow-hidden">
{/* MMSI 표시 */}
{mmsiParam && (
<div className="absolute top-3 left-3 z-10 bg-card/95 backdrop-blur-sm rounded-lg border border-border px-3 py-2">
<div className="flex items-center gap-2">
<Ship className="w-4 h-4 text-blue-400" />
<span className="text-[11px] font-bold text-heading">MMSI: {mmsiParam}</span>
{vessel && (
<Badge className={`border-0 text-[9px] ${riskConfig.bg} ${riskConfig.color}`}>
: {riskConfig.label}
</Badge>
)}
</div>
</div>
)}
{/* MapLibre GL + deck.gl 지도 */}
<BaseMap
ref={mapRef}
center={[34.5, 126.5]}
@ -365,9 +406,8 @@ export function VesselDetail() {
</span>
<span className="text-[8px]">
<span className="text-blue-400 font-bold">UTC</span>
<span className="text-label font-mono ml-1">2023-07-10() 12:32:45</span>
<span className="text-label font-mono ml-1">{new Date().toISOString().substring(0, 19).replace('T', ' ')}</span>
</span>
<span className="ml-auto text-[7px] text-hint">8,531 | 0 25 50NM</span>
</div>
</div>

파일 보기

@ -50,6 +50,7 @@ export async function getEvents(params?: {
status?: string;
level?: string;
category?: string;
vesselMmsi?: string;
page?: number;
size?: number;
}): Promise<EventPageResponse> {
@ -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' });