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:
부모
cc1b1e20df
커밋
6ac9184016
@ -34,11 +34,12 @@ public class EventController {
|
|||||||
@RequestParam(required = false) String status,
|
@RequestParam(required = false) String status,
|
||||||
@RequestParam(required = false) String level,
|
@RequestParam(required = false) String level,
|
||||||
@RequestParam(required = false) String category,
|
@RequestParam(required = false) String category,
|
||||||
|
@RequestParam(required = false) String vesselMmsi,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size
|
@RequestParam(defaultValue = "20") int size
|
||||||
) {
|
) {
|
||||||
return eventService.getEvents(
|
return eventService.getEvents(
|
||||||
status, level, category,
|
status, level, category, vesselMmsi,
|
||||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "occurredAt"))
|
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "occurredAt"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,7 +32,7 @@ public class EventService {
|
|||||||
* 이벤트 목록 조회 (필터 조합).
|
* 이벤트 목록 조회 (필터 조합).
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@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);
|
Specification<PredictionEvent> spec = Specification.where(null);
|
||||||
|
|
||||||
if (status != null && !status.isBlank()) {
|
if (status != null && !status.isBlank()) {
|
||||||
@ -44,6 +44,9 @@ public class EventService {
|
|||||||
if (category != null && !category.isBlank()) {
|
if (category != null && !category.isBlank()) {
|
||||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("category"), category));
|
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
|
// 기본 정렬: occurredAt DESC
|
||||||
return eventRepository.findAll(spec, pageable);
|
return eventRepository.findAll(spec, pageable);
|
||||||
|
|||||||
@ -4,9 +4,23 @@ import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLay
|
|||||||
import type { MarkerData } from '@lib/map';
|
import type { MarkerData } from '@lib/map';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { AlertTriangle, Ship, Radio, Layers, Zap, Activity, Clock, Pin } from 'lucide-react';
|
import { AlertTriangle, Ship, Radio, Zap, Activity, Clock, Pin, Loader2, WifiOff } from 'lucide-react';
|
||||||
import { useVesselStore } from '@stores/vesselStore';
|
import {
|
||||||
import { useEventStore } from '@stores/eventStore';
|
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 {
|
interface MapEvent {
|
||||||
id: string;
|
id: string;
|
||||||
@ -18,9 +32,9 @@ interface MapEvent {
|
|||||||
risk: number;
|
risk: number;
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
|
level: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const EVENT_COLORS: Record<string, string> = {
|
const EVENT_COLORS: Record<string, string> = {
|
||||||
'EEZ 침범': '#ef4444',
|
'EEZ 침범': '#ef4444',
|
||||||
'다크베셀': '#f97316',
|
'다크베셀': '#f97316',
|
||||||
@ -33,6 +47,8 @@ const eventIconMap: Record<string, typeof AlertTriangle> = {
|
|||||||
'AIS 신호 소실': Radio,
|
'AIS 신호 소실': Radio,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_VESSEL_MARKERS = 100;
|
||||||
|
|
||||||
function RiskBar({ value, size = 'md' }: { value: number; size?: 'sm' | 'md' }) {
|
function RiskBar({ value, size = 'md' }: { value: number; size?: 'sm' | 'md' }) {
|
||||||
const pct = value * 100;
|
const pct = value * 100;
|
||||||
const h = size === 'sm' ? 'h-1' : 'h-1.5';
|
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() {
|
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(
|
const mapEvents: MapEvent[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
storeEvents.slice(0, 3).map((e) => ({
|
activeEvents
|
||||||
id: e.id,
|
.filter((e) => e.lat != null && e.lon != null)
|
||||||
type: e.type,
|
.map((e) => ({
|
||||||
mmsi: e.mmsi ?? '미상',
|
id: String(e.id),
|
||||||
nationality: e.mmsi?.startsWith('412') ? 'CN' : e.mmsi?.startsWith('440') ? 'KR' : '미상',
|
type: e.category,
|
||||||
time: e.time.split(' ')[1] ?? e.time,
|
mmsi: e.vesselMmsi ?? '미상',
|
||||||
vesselName: e.vesselName ?? '미상',
|
nationality: e.vesselMmsi?.startsWith('412') ? 'CN' : e.vesselMmsi?.startsWith('440') ? 'KR' : '미상',
|
||||||
risk: (e.level === 'CRITICAL' ? 0.94 : e.level === 'HIGH' ? 0.91 : 0.88),
|
time: e.occurredAt.includes(' ') ? e.occurredAt.split(' ')[1]?.slice(0, 5) ?? e.occurredAt : e.occurredAt,
|
||||||
lat: e.lat ?? 0,
|
vesselName: e.vesselName ?? '미상',
|
||||||
lng: e.lng ?? 0,
|
risk: e.aiConfidence ?? (e.level === 'CRITICAL' ? 0.94 : e.level === 'HIGH' ? 0.91 : 0.88),
|
||||||
})),
|
lat: e.lat!,
|
||||||
[storeEvents],
|
lng: e.lon!,
|
||||||
|
level: e.level,
|
||||||
|
})),
|
||||||
|
[activeEvents],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Map store vessels into AIS display list
|
// 선박 분석 데이터 → 마커용 변환 (좌표 없으므로 zone 기반 더미 좌표 생성)
|
||||||
const aisVessels = useMemo(
|
// vessel_analysis에는 좌표가 없으므로 zone 기반 대략적 배치
|
||||||
() =>
|
const vesselMarkers = useMemo(() => {
|
||||||
vessels.map((v) => ({
|
// zone → 대략적 좌표 매핑
|
||||||
lat: v.lat,
|
const ZONE_COORDS: Record<string, { lat: number; lng: number }> = {
|
||||||
lng: v.lng,
|
WEST_SEA: { lat: 36.5, lng: 124.5 },
|
||||||
name: v.name,
|
SOUTH_SEA: { lat: 33.5, lng: 127.0 },
|
||||||
type: v.type,
|
EAST_SEA: { lat: 37.0, lng: 130.0 },
|
||||||
speed: v.speed != null ? `${v.speed}kt` : '미상',
|
JEJU: { lat: 33.2, lng: 126.5 },
|
||||||
heading: v.heading ?? 0,
|
NLL: { lat: 37.8, lng: 125.0 },
|
||||||
})),
|
};
|
||||||
[vessels],
|
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 [selectedEvent, setSelectedEvent] = useState<MapEvent | null>(null);
|
||||||
const mapRef = useRef<MapHandle>(null);
|
const mapRef = useRef<MapHandle>(null);
|
||||||
@ -95,28 +163,28 @@ export function LiveMapView() {
|
|||||||
}
|
}
|
||||||
}, [mapEvents, selectedEvent]);
|
}, [mapEvents, selectedEvent]);
|
||||||
|
|
||||||
// deck.gl 레이어: 선택 이벤트에 따라 마커 크기 변경
|
// deck.gl 레이어
|
||||||
const buildLayers = useCallback(() => [
|
const buildLayers = useCallback(() => [
|
||||||
...STATIC_LAYERS,
|
...STATIC_LAYERS,
|
||||||
// 일반 AIS 선박
|
// 선박 분석 데이터 마커 (riskLevel 기반 색상)
|
||||||
createMarkerLayer(
|
createMarkerLayer(
|
||||||
'ais-vessels',
|
'ais-vessels',
|
||||||
aisVessels.map((v): MarkerData => {
|
vesselMarkers.map((v): MarkerData => {
|
||||||
const isPatrol = v.type === '경비함' || v.type === '순찰선';
|
const level = v.item.algorithms.riskScore.level;
|
||||||
const isKorean = v.type === '한국어선';
|
const color = RISK_MARKER_COLOR[level] ?? '#6b7280';
|
||||||
return {
|
return {
|
||||||
lat: v.lat,
|
lat: v.lat,
|
||||||
lng: v.lng,
|
lng: v.lng,
|
||||||
color: isPatrol ? '#a855f7' : isKorean ? '#3b82f6' : '#64748b',
|
color,
|
||||||
radius: isPatrol ? 900 : 600,
|
radius: level === 'CRITICAL' ? 900 : level === 'HIGH' ? 750 : 600,
|
||||||
label: v.name,
|
label: v.item.mmsi,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
// 이벤트 경보 반경 원
|
// 이벤트 경보 반경 원
|
||||||
createRadiusLayer(
|
createRadiusLayer(
|
||||||
'alert-radius',
|
'alert-radius',
|
||||||
mapEvents.map(evt => ({
|
mapEvents.map((evt) => ({
|
||||||
lat: evt.lat,
|
lat: evt.lat,
|
||||||
lng: evt.lng,
|
lng: evt.lng,
|
||||||
radius: 8000,
|
radius: 8000,
|
||||||
@ -134,11 +202,11 @@ export function LiveMapView() {
|
|||||||
radius: evt.id === selectedEvent?.id ? 1600 : 1100,
|
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 handleMapClick = useCallback((info: unknown) => {
|
||||||
const pickInfo = info as { layer?: { id: string }; index?: number };
|
const pickInfo = info as { layer?: { id: string }; index?: number };
|
||||||
if (pickInfo.layer?.id === 'event-markers' && pickInfo.index != null) {
|
if (pickInfo.layer?.id === 'event-markers' && pickInfo.index != null) {
|
||||||
@ -150,7 +218,6 @@ export function LiveMapView() {
|
|||||||
// 지도 인스턴스 접근 (flyTo용)
|
// 지도 인스턴스 접근 (flyTo용)
|
||||||
const handleMapReady = useCallback((map: maplibregl.Map) => {
|
const handleMapReady = useCallback((map: maplibregl.Map) => {
|
||||||
mapInstanceRef.current = map;
|
mapInstanceRef.current = map;
|
||||||
// 초기 선택 이벤트로 포커스
|
|
||||||
const first = mapEvents[0];
|
const first = mapEvents[0];
|
||||||
if (first) {
|
if (first) {
|
||||||
map.flyTo({ center: [first.lng, first.lat], zoom: 9, speed: 0.6 });
|
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>
|
<h2 className="text-lg font-bold text-heading">실시간 이벤트</h2>
|
||||||
<p className="text-[11px] text-hint mt-0.5">현재 진행 중인 의심 활동</p>
|
<p className="text-[11px] text-hint mt-0.5">현재 진행 중인 의심 활동</p>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
{mapEvents.map((evt) => {
|
{mapEvents.map((evt) => {
|
||||||
const IconComp = eventIconMap[evt.type] || AlertTriangle;
|
const IconComp = eventIconMap[evt.type] || AlertTriangle;
|
||||||
@ -201,6 +286,9 @@ export function LiveMapView() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{!loading && mapEvents.length === 0 && (
|
||||||
|
<div className="text-[11px] text-hint text-center py-4">활성 이벤트가 없습니다.</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -212,13 +300,11 @@ export function LiveMapView() {
|
|||||||
<div className="text-[9px] text-muted-foreground font-bold mb-1">선박 범례</div>
|
<div className="text-[9px] text-muted-foreground font-bold mb-1">선박 범례</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{[
|
{[
|
||||||
{ color: '#ef4444', label: 'EEZ 침범' },
|
{ color: '#ef4444', label: 'CRITICAL' },
|
||||||
{ color: '#f97316', label: '다크베셀' },
|
{ color: '#f97316', label: 'HIGH' },
|
||||||
{ color: '#eab308', label: 'AIS 소실' },
|
{ color: '#3b82f6', label: 'MEDIUM' },
|
||||||
{ color: '#a855f7', label: '경비함정' },
|
{ color: '#6b7280', label: 'LOW' },
|
||||||
{ color: '#3b82f6', label: '한국어선' },
|
].map((l) => (
|
||||||
{ color: '#64748b', label: '중국어선' },
|
|
||||||
].map(l => (
|
|
||||||
<div key={l.label} className="flex items-center gap-1.5">
|
<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 }} />
|
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: l.color }} />
|
||||||
<span className="text-[8px] text-muted-foreground">{l.label}</span>
|
<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="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" />
|
<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-[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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -287,23 +373,23 @@ export function LiveMapView() {
|
|||||||
<div className="border-l-2 border-red-500 pl-3">
|
<div className="border-l-2 border-red-500 pl-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<AlertTriangle className="w-3 h-3 text-red-400" />
|
<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>
|
||||||
<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>
|
||||||
<div className="border-l-2 border-orange-500 pl-3">
|
<div className="border-l-2 border-orange-500 pl-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<Activity className="w-3 h-3 text-orange-400" />
|
<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>
|
||||||
<div className="text-[10px] text-hint mt-0.5">관측값: 42분 / 기준: > 30분</div>
|
<div className="text-[10px] text-hint mt-0.5">좌표: {selectedEvent.lat.toFixed(4)}, {selectedEvent.lng.toFixed(4)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-l-2 border-green-500 pl-3">
|
<div className="border-l-2 border-green-500 pl-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<Clock className="w-3 h-3 text-green-400" />
|
<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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-hint mt-3">이 판단 근거는 AI 모델 분석 결과이며, 최종 판단은 관리자가 수행합니다.</p>
|
<p className="text-[10px] text-hint mt-3">이 판단 근거는 AI 모델 분석 결과이며, 최종 판단은 관리자가 수행합니다.</p>
|
||||||
|
|||||||
@ -1,97 +1,58 @@
|
|||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
Search, ChevronDown, ChevronUp, ChevronRight, Plus, X,
|
Search,
|
||||||
Ship, AlertTriangle, Radar, Anchor, MapPin, Printer,
|
Ship, AlertTriangle, Radar, MapPin, Printer,
|
||||||
Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain
|
Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain,
|
||||||
|
Loader2, WifiOff, ShieldAlert,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map';
|
import { BaseMap, STATIC_LAYERS, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map';
|
||||||
import type { MarkerData } 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 VesselPermitData {
|
||||||
// ─── 선박 데이터 ──────────────────────
|
|
||||||
interface VesselTrack {
|
|
||||||
id: string;
|
|
||||||
mmsi: string;
|
mmsi: string;
|
||||||
callSign: string;
|
vesselName: string | null;
|
||||||
source: string;
|
vesselNameCn: string | null;
|
||||||
name: string;
|
flagCountry: string | null;
|
||||||
type: string;
|
vesselType: string | null;
|
||||||
country: string;
|
tonnage: number | null;
|
||||||
detail: Record<string, string>;
|
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[] = [
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── 특이운항 / 비허가 선박 ──────────────
|
async function fetchVesselPermit(mmsi: string): Promise<VesselPermitData | null> {
|
||||||
const ALERT_VESSELS = [
|
try {
|
||||||
{ name: '제303 대양호', highlight: true },
|
const res = await fetch(`${API_BASE}/vessel-permits/${encodeURIComponent(mmsi)}`, {
|
||||||
{ name: '제609 한일호', highlight: false },
|
credentials: 'include',
|
||||||
{ name: '한진아일랜드 고속훼리', highlight: false },
|
});
|
||||||
];
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
// ─── AI 조업 분석 데이터 ─────────────────
|
} catch {
|
||||||
interface FishingAnalysis {
|
return null;
|
||||||
no: number;
|
}
|
||||||
mmsi: string;
|
|
||||||
name: string;
|
|
||||||
eezPermit: '허가' | '무허가';
|
|
||||||
vesselType: '어선' | '어구';
|
|
||||||
gearType: string;
|
|
||||||
gearIcon: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FISHING_ANALYSIS: FishingAnalysis[] = [
|
// ─── 위험도 레벨 → 색상 매핑 ──────────────
|
||||||
{ no: 1, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '무허가', vesselType: '어구', gearType: '쌍끌이', gearIcon: '🚢' },
|
const RISK_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
{ no: 2, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '범장망', gearIcon: '🚢' },
|
CRITICAL: { label: '심각', color: 'text-red-400', bg: 'bg-red-500/15' },
|
||||||
{ no: 3, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' },
|
HIGH: { label: '높음', color: 'text-orange-400', bg: 'bg-orange-500/15' },
|
||||||
{ no: 4, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' },
|
MEDIUM: { label: '보통', color: 'text-yellow-400', bg: 'bg-yellow-500/15' },
|
||||||
{ no: 5, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' },
|
LOW: { label: '낮음', color: 'text-blue-400', bg: 'bg-blue-500/15' },
|
||||||
];
|
};
|
||||||
|
|
||||||
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 RIGHT_TOOLS = [
|
const RIGHT_TOOLS = [
|
||||||
{ icon: Crosshair, label: '구역설정' }, { icon: Ruler, label: '거리' },
|
{ icon: Crosshair, label: '구역설정' }, { icon: Ruler, label: '거리' },
|
||||||
@ -102,248 +63,328 @@ const RIGHT_TOOLS = [
|
|||||||
// ─── 메인 컴포넌트 ────────────────────
|
// ─── 메인 컴포넌트 ────────────────────
|
||||||
|
|
||||||
export function VesselDetail() {
|
export function VesselDetail() {
|
||||||
const [expandedId, setExpandedId] = useState<string | null>('2');
|
const { id: mmsiParam } = useParams<{ id: string }>();
|
||||||
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 [vessel, setVessel] = useState<VesselAnalysisItem | null>(null);
|
||||||
const [showAiPanel, setShowAiPanel] = useState(false);
|
const [permit, setPermit] = useState<VesselPermitData | null>(null);
|
||||||
const [gearChecks, setGearChecks] = useState<Record<string, boolean>>({ '쌍끌이': true, '범장망': true });
|
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 mapRef = useRef<MapHandle>(null);
|
||||||
|
|
||||||
const buildLayers = useCallback(() => [
|
// 데이터 로드
|
||||||
...STATIC_LAYERS,
|
useEffect(() => {
|
||||||
|
if (!mmsiParam) {
|
||||||
|
setLoading(false);
|
||||||
|
setError('MMSI 파라미터가 필요합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 관할해역 구역
|
let cancelled = false;
|
||||||
createZoneLayer('jurisdiction', JURISDICTION_AREAS.map(a => ({
|
|
||||||
name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000,
|
|
||||||
})), 80000, 0.05),
|
|
||||||
|
|
||||||
// 등심선
|
const loadData = async () => {
|
||||||
...DEPTH_CONTOURS.map((contour, i) =>
|
setLoading(true);
|
||||||
createPolylineLayer(`depth-${i}`, contour.points as [number, number][], {
|
setError(null);
|
||||||
color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4],
|
|
||||||
})
|
|
||||||
),
|
|
||||||
|
|
||||||
// 선박 마커
|
try {
|
||||||
createMarkerLayer('vessels', MAP_MARKERS.map((m): MarkerData => {
|
const [analysisRes, permitRes, eventsRes] = await Promise.all([
|
||||||
const lat = 34.2 + Math.random() * 2;
|
fetchVesselAnalysis().catch(() => null),
|
||||||
const lng = 125.5 + Math.random() * 3;
|
fetchVesselPermit(mmsiParam),
|
||||||
return { lat, lng, color: '#3b82f6', radius: 800, label: m.label };
|
getEvents({ vesselMmsi: mmsiParam, size: 10 }).catch(() => null),
|
||||||
})),
|
]);
|
||||||
|
|
||||||
// VTS 마커
|
if (cancelled) return;
|
||||||
createMarkerLayer('vts', VTS_MARKERS.map((m): MarkerData => ({
|
|
||||||
lat: 34.0, lng: 126.2, color: '#eab308', radius: 800, label: m.label,
|
|
||||||
}))),
|
|
||||||
|
|
||||||
// 함정 마커
|
if (!analysisRes) {
|
||||||
createMarkerLayer('patrols', PATROL_MARKERS.map((m): MarkerData => ({
|
setServiceAvailable(false);
|
||||||
lat: 33.5 + Math.random(), lng: 127.0 + Math.random(), color: '#a855f7', radius: 800, label: m.label,
|
setPermit(permitRes);
|
||||||
}))),
|
setEvents(eventsRes?.content ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 클러스터
|
setServiceAvailable(analysisRes.serviceAvailable);
|
||||||
createMarkerLayer('clusters', CLUSTERS.map((c, i): MarkerData => ({
|
const found = analysisRes.items.find((item) => item.mmsi === mmsiParam) ?? null;
|
||||||
lat: 33.0 + i * 0.8, lng: 125.5 + i * 0.5, color: '#6b7280', radius: 2400, label: `${c.n}척`,
|
setVessel(found);
|
||||||
}))),
|
setPermit(permitRes);
|
||||||
|
setEvents(eventsRes?.content ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : '데이터 로드 실패');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 선박충돌 알림
|
loadData();
|
||||||
createMarkerLayer('alerts', [{
|
return () => { cancelled = true; };
|
||||||
lat: 33.8, lng: 127.5, color: '#ef4444', radius: 1400, label: '선박충돌',
|
}, [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, []);
|
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 (
|
return (
|
||||||
<div className="flex h-[calc(100vh-7.5rem)] gap-0 -m-4">
|
<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="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">
|
<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">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-[9px] text-hint w-14 shrink-0">시작/종료</span>
|
<span className="text-[9px] text-hint w-14 shrink-0">시작/종료</span>
|
||||||
<input value={startDate} onChange={(e) => setStartDate(e.target.value)}
|
<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>
|
<span className="text-hint text-[10px]">~</span>
|
||||||
<input value={endDate} onChange={(e) => setEndDate(e.target.value)}
|
<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>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-[9px] text-hint w-14 shrink-0">조회간격</span>
|
<span className="text-[9px] text-hint w-14 shrink-0">MMSI</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">
|
<input value={searchMmsi} onChange={(e) => setSearchMmsi(e.target.value)}
|
||||||
<option>전체</option><option>1분</option><option>5분</option><option>10분</option><option>30분</option>
|
placeholder="MMSI 입력"
|
||||||
</select>
|
|
||||||
<span className="text-[9px] text-hint ml-2 shrink-0">선박ID</span>
|
|
||||||
<input value={shipId} onChange={(e) => setShipId(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" />
|
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">
|
<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" />
|
검색 <Search className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선박 카드 */}
|
{/* 로딩/에러 상태 */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
{loading && (
|
||||||
{VESSELS.map((v) => {
|
<div className="flex-1 flex items-center justify-center">
|
||||||
const isOpen = expandedId === v.id;
|
<Loader2 className="w-6 h-6 text-blue-400 animate-spin" />
|
||||||
return (
|
<span className="ml-2 text-sm text-hint">데이터 로드 중...</span>
|
||||||
<div key={v.id} className="border-b border-border">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
{isOpen && (
|
{error && !loading && (
|
||||||
<div className="px-3 pb-3">
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 px-4">
|
||||||
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
|
<AlertTriangle className="w-8 h-8 text-red-400" />
|
||||||
{Object.entries(v.detail).map(([k, val], i) => (
|
<span className="text-sm text-red-400 text-center">{error}</span>
|
||||||
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* ── 중앙: 지도 ── */}
|
{!serviceAvailable && !loading && !error && (
|
||||||
<div className="flex-1 relative bg-card/40 overflow-hidden">
|
<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" />
|
||||||
<div className="absolute top-3 left-3 z-10 flex gap-2">
|
<span className="text-[11px] text-yellow-400 font-medium">분석 서비스 오프라인</span>
|
||||||
{(['특이운항', '비허가/재제선박'] 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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<p className="text-[10px] text-hint mt-1">iran 백엔드가 연결되지 않아 분석 데이터를 표시할 수 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* AI 조업 분석 패널 (토글) */}
|
{/* 선박 정보 */}
|
||||||
<button
|
{!loading && !error && (
|
||||||
onClick={() => setShowAiPanel(!showAiPanel)}
|
<div className="flex-1 overflow-y-auto">
|
||||||
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"
|
{/* 기본 정보 카드 */}
|
||||||
>
|
<div className="p-3 border-b border-border">
|
||||||
<Brain className="w-3.5 h-3.5" />AI 조업 분석
|
<div className="flex items-center gap-2 mb-2">
|
||||||
</button>
|
<Ship className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-[11px] font-bold text-heading">기본 정보</span>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowAiPanel(false)} className="text-hint hover:text-heading"><X className="w-4 h-4" /></button>
|
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
|
||||||
</div>
|
{[
|
||||||
|
['MMSI', mmsiParam ?? '-'],
|
||||||
{/* 선택선박 + 조업식별 필터 */}
|
['선박 유형', vessel?.classification.vesselType ?? permit?.vesselType ?? '-'],
|
||||||
<div className="px-4 py-2.5 border-b border-border">
|
['국적', permit?.flagCountry ?? '-'],
|
||||||
<div className="flex items-center gap-3 mb-2">
|
['선명', permit?.vesselName ?? '-'],
|
||||||
<div className="flex items-center gap-1.5 text-[10px]">
|
['선명(중문)', permit?.vesselNameCn ?? '-'],
|
||||||
<Anchor className="w-3 h-3 text-muted-foreground" />
|
['톤수', permit?.tonnage != null ? `${permit.tonnage}톤` : '-'],
|
||||||
<span className="text-muted-foreground">선택선박</span>
|
['길이', permit?.lengthM != null ? `${permit.lengthM}m` : '-'],
|
||||||
<span className="text-heading font-bold text-sm">50</span>
|
['건조년도', permit?.buildYear != null ? String(permit.buildYear) : '-'],
|
||||||
<span className="text-hint">척</span>
|
['구역', vessel?.algorithms.location.zone ?? '-'],
|
||||||
</div>
|
['기선거리', vessel?.algorithms.location.distToBaselineNm != null
|
||||||
<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">
|
? `${vessel.algorithms.location.distToBaselineNm.toFixed(1)}nm` : '-'],
|
||||||
<Search className="w-3 h-3" />검색
|
['시즌', vessel?.classification.season ?? '-'],
|
||||||
</button>
|
].map(([k, v], i) => (
|
||||||
</div>
|
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
|
||||||
<span className="text-[9px] text-hint mr-1 flex items-center gap-1"><Radar className="w-3 h-3" />조업식별</span>
|
<span className="flex-1 px-2.5 py-1.5 text-label">{v}</span>
|
||||||
{GEAR_FILTERS.map((g) => (
|
</div>
|
||||||
<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>
|
</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">
|
{permit && (
|
||||||
<span>구분</span>
|
<div className="p-3 border-b border-border">
|
||||||
<span>선박ID/선박명</span>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span>EEZ허가</span>
|
<ShieldAlert className="w-4 h-4 text-green-400" />
|
||||||
<span>어선/어구</span>
|
<span className="text-[11px] font-bold text-heading">허가 정보</span>
|
||||||
<span>조업식별</span>
|
</div>
|
||||||
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 테이블 행 */}
|
{/* AI 분석 결과 */}
|
||||||
<div className="max-h-[300px] overflow-y-auto">
|
{vessel && (
|
||||||
{FISHING_ANALYSIS.map((row) => (
|
<div className="p-3 border-b border-border">
|
||||||
<div
|
<div className="flex items-center gap-2 mb-2">
|
||||||
key={row.no}
|
<Brain className="w-4 h-4 text-purple-400" />
|
||||||
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-[11px] font-bold text-heading">AI 분석 결과</span>
|
||||||
>
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground">{row.no}</span>
|
|
||||||
<div>
|
{/* 위험도 점수 */}
|
||||||
<div className="text-[8px] text-hint">ID | {row.mmsi}</div>
|
<div className="mb-3 p-2 bg-surface-overlay rounded border border-slate-700/20">
|
||||||
<div className="text-[11px] font-bold text-heading">{row.name}</div>
|
<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>
|
</div>
|
||||||
<span className={`text-[10px] font-bold ${row.eezPermit === '무허가' ? 'text-red-400' : 'text-green-400'}`}>
|
<div className="flex items-baseline gap-1 mb-1">
|
||||||
{row.eezPermit}
|
<span className={`text-xl font-bold ${riskConfig.color}`}>
|
||||||
</span>
|
{Math.round(riskScore * 100)}
|
||||||
<span className="text-[10px] text-label">{row.vesselType}</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<span className="text-[10px] text-hint">/100</span>
|
||||||
{row.gearType !== '-' && (
|
</div>
|
||||||
<Badge className={`text-[8px] px-1.5 py-0.5 border-0 ${
|
<div className="h-1.5 bg-switch-background rounded-full overflow-hidden">
|
||||||
row.gearType === '쌍끌이' ? 'bg-orange-500/20 text-orange-400'
|
<div
|
||||||
: row.gearType === '범장망' ? 'bg-purple-500/20 text-purple-400'
|
className="h-1.5 bg-gradient-to-r from-red-600 to-red-400 rounded-full transition-all"
|
||||||
: 'bg-muted text-muted-foreground'
|
style={{ width: `${riskScore * 100}%` }}
|
||||||
}`}>
|
/>
|
||||||
{row.gearIcon && <span className="mr-0.5">{row.gearIcon}</span>}
|
|
||||||
{row.gearType}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{row.gearType === '-' && <span className="text-[10px] text-hint">-</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* MapLibre GL + deck.gl 지도 */}
|
|
||||||
<BaseMap
|
<BaseMap
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
center={[34.5, 126.5]}
|
center={[34.5, 126.5]}
|
||||||
@ -365,9 +406,8 @@ export function VesselDetail() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[8px]">
|
<span className="text-[8px]">
|
||||||
<span className="text-blue-400 font-bold">UTC</span>
|
<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>
|
||||||
<span className="ml-auto text-[7px] text-hint">8,531 | 0 25 50NM</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -50,6 +50,7 @@ export async function getEvents(params?: {
|
|||||||
status?: string;
|
status?: string;
|
||||||
level?: string;
|
level?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
|
vesselMmsi?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
}): Promise<EventPageResponse> {
|
}): Promise<EventPageResponse> {
|
||||||
@ -57,6 +58,7 @@ export async function getEvents(params?: {
|
|||||||
if (params?.status) query.set('status', params.status);
|
if (params?.status) query.set('status', params.status);
|
||||||
if (params?.level) query.set('level', params.level);
|
if (params?.level) query.set('level', params.level);
|
||||||
if (params?.category) query.set('category', params.category);
|
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('page', String(params?.page ?? 0));
|
||||||
query.set('size', String(params?.size ?? 20));
|
query.set('size', String(params?.size ?? 20));
|
||||||
const res = await fetch(`${API_BASE}/events?${query}`, { credentials: 'include' });
|
const res = await fetch(`${API_BASE}/events?${query}`, { credentials: 'include' });
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user