kcg-ai-monitoring/frontend/src/features/detection/components/VesselMiniMap.tsx
htlee d82eaf7e79 feat(frontend): 중국어선 감시 실데이터 연동 + 특이운항 미니맵/판별 패널
Tab 1 AI 감시 대시보드 / Tab 2 환적탐지 / Tab 3 어구판별 3개 탭을
deprecated iran proxy 에서 자체 /api/analysis/* 로 전환하고, 특이운항
선박의 24h 항적과 판별 구간 상세를 지도와 패널로 제공한다.

서비스 계층
- analysisApi.ts 확장: getAnalysisStats / getAnalysisVessels(필터 3종) /
  getGearDetections 추가. VesselAnalysis 에 violationCategories /
  bd09OffsetM / ucafScore / ucftScore / clusterId 필드 노출
- analysisAdapter.ts: flat VesselAnalysis → nested VesselAnalysisItem
  변환으로 기존 컴포넌트 재사용
- vesselAnalysisApi.ts fetchVesselAnalysis @deprecated 마킹

Tab 1 (ChinaFishing)
- 서버 집계(stats) 기준 카운터 재구성. 중국어선 / Dark / 환적 / 고위험
  모두 mmsiPrefix=412 로 서버 필터
- 선박 리스트 vessel_type UNKNOWN 인 경우 "중국어선" + "미분류" 로 표시
- 특이운항 row 클릭 → 아래 행에 미니맵 + 판별 패널 배치
- 관심영역 / VIIRS / 기상 / VTS 카드에 "데모 데이터" 뱃지. 비허가 /
  제재 / 관심 탭 disabled + "준비중" 뱃지

Tab 2 (RealVesselAnalysis)
- /analysis/dark / /analysis/transship / /analysis/vessels mode별 분기
- 상단 통계 카드를 items 클라이언트 집계로 전환해 하단 테이블과 정합

Tab 3 (GearIdentification)
- 최하단 "최근 자동탐지 결과" 섹션 추가. row 클릭 시 상단 입력 폼
  프리필 + 결과 패널에 자동탐지 근거 프리셋

특이운항 판별 시각화 (VesselMiniMap / VesselAnomalyPanel /
vesselAnomaly 유틸)
- 24h getAnalysisHistory 로드 → classifyAnomaly 로 DARK/SPOOFING/
  TRANSSHIP/GEAR_VIOLATION/HIGH_RISK 5개 카테고리 판별. 좌표는
  top-level lat/lon 우선, features.gap_start_* fallback
- groupAnomaliesToSegments: 5분 주기 반복되는 동일 신호를 시작~종료
  구간으로 병합
- 미니맵: 전체 궤적은 연한 파랑, segment 시간범위와 매칭되는 AIS
  궤적 서브구간을 severity 색(CRITICAL 빨강 / WARNING 주황 / INFO
  파랑) 으로 하이라이트. 이벤트 기준 좌표는 작은 흰 점
- 판별 패널: 시작→종료 · 지속 · N회 연속 감지 · 카테고리 뱃지 · 설명
2026-04-16 14:31:26 +09:00

260 lines
10 KiB
TypeScript

/**
* 선박 궤적 미니맵 — 단일 MMSI 24h 항적 정적 표시.
* fetchVesselTracks (signal-batch 프록시) 호출 → PathLayer 로 그림.
*/
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Loader2, Ship, Clock, X } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PathLayer, ScatterplotLayer } from 'deck.gl';
import type { Layer } from 'deck.gl';
import { BaseMap, type MapHandle } from '@lib/map';
import { useMapLayers } from '@lib/map/hooks/useMapLayers';
import { fetchVesselTracks, type VesselTrack } from '@/services/vesselAnalysisApi';
import type { AnomalySegment } from './vesselAnomaly';
interface Props {
mmsi: string;
vesselName?: string;
hoursBack?: number;
segments?: AnomalySegment[];
onClose?: () => void;
}
function fmt(ts: string | number): string {
const n = typeof ts === 'string' ? parseInt(ts, 10) : ts;
if (!Number.isFinite(n)) return '-';
const d = new Date(n * 1000);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${mm}/${dd} ${hh}:${mi}`;
}
export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [], onClose }: Props) {
const mapRef = useRef<MapHandle | null>(null);
const [track, setTrack] = useState<VesselTrack | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true); setError(''); setTrack(null);
try {
const end = new Date();
const start = new Date(end.getTime() - hoursBack * 3600 * 1000);
const res = await fetchVesselTracks(
[mmsi],
start.toISOString(),
end.toISOString(),
);
setTrack(res[0] ?? null);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '궤적 조회 실패');
} finally {
setLoading(false);
}
}, [mmsi, hoursBack]);
useEffect(() => { load(); }, [load]);
// 궤적 로드 후 bounds 로 지도 이동
useEffect(() => {
if (!track || track.geometry.length === 0) return;
const map = mapRef.current?.map;
if (!map) return;
const lons = track.geometry.map((p) => p[0]);
const lats = track.geometry.map((p) => p[1]);
const w = Math.min(...lons), e = Math.max(...lons);
const s = Math.min(...lats), n = Math.max(...lats);
const span = Math.max(e - w, n - s);
if (span < 0.001) {
map.setCenter([(w + e) / 2, (s + n) / 2]);
map.setZoom(11);
} else {
map.fitBounds([[w, s], [e, n]], { padding: 24, maxZoom: 11, duration: 0 });
}
}, [track]);
// segment 의 [startTime, endTime] 범위에 들어오는 AIS 궤적 포인트를 뽑아 severity 색 path로 덧그린다.
// 이게 사용자가 '어떤 시간대 궤적이 특이운항으로 판별됐는지' 를 바로 알게 해주는 핵심 표시.
const segmentPaths = useMemo((): Array<{ id: string; path: [number, number][]; severity: AnomalySegment['severity'] }> => {
if (!track || track.timestamps.length === 0 || segments.length === 0) return [];
if (track.timestamps.length !== track.geometry.length) return [];
const epochs = track.timestamps.map((t) => Number(t) * 1000);
return segments
.map((seg) => {
const start = new Date(seg.startTime).getTime();
const end = new Date(seg.endTime).getTime();
const path: [number, number][] = [];
for (let i = 0; i < epochs.length; i++) {
if (epochs[i] >= start && epochs[i] <= end) path.push(track.geometry[i]);
}
return { id: seg.id, path, severity: seg.severity };
})
.filter((s) => s.path.length >= 2);
}, [track, segments]);
useMapLayers(mapRef, (): Layer[] => {
const layers: Layer[] = [];
if (track && track.geometry.length >= 2) {
layers.push(
new PathLayer({
id: `mini-track-${mmsi}`,
data: [{ path: track.geometry }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [59, 130, 246, 140],
getWidth: 2,
widthUnits: 'pixels',
jointRounded: true,
capRounded: true,
}),
);
}
if (segmentPaths.length > 0) {
layers.push(
new PathLayer<{ id: string; path: [number, number][]; severity: AnomalySegment['severity'] }>({
id: `mini-segment-paths-${mmsi}`,
data: segmentPaths,
getPath: (d) => d.path,
getColor: (d) =>
d.severity === 'critical' ? [239, 68, 68, 240]
: d.severity === 'warning' ? [249, 115, 22, 230]
: [59, 130, 246, 210],
getWidth: 4,
widthUnits: 'pixels',
widthMinPixels: 3,
jointRounded: true,
capRounded: true,
}),
);
}
if (track && track.geometry.length >= 2) {
layers.push(
new PathLayer({
id: `mini-track-head-${mmsi}`,
data: [{ path: track.geometry.slice(-2) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [239, 68, 68, 255],
getWidth: 4,
widthUnits: 'pixels',
}),
);
}
// 이벤트 기준 좌표 (gap 시작점 등) — 분석 시각이 아니라 판별 근거가 된 과거 시점.
// 반복 분석이 같은 좌표를 참조하는 경우가 많아 작게/반투명하게 표시한다.
const geoSegments = segments.filter(
(s): s is AnomalySegment & { representativeLat: number; representativeLon: number } =>
s.representativeLat != null && s.representativeLon != null,
);
if (geoSegments.length > 0) {
layers.push(
new ScatterplotLayer<AnomalySegment & { representativeLat: number; representativeLon: number }>({
id: `mini-segments-${mmsi}`,
data: geoSegments,
getPosition: (d) => [d.representativeLon, d.representativeLat],
getRadius: 4,
radiusUnits: 'pixels',
radiusMinPixels: 4,
radiusMaxPixels: 6,
getFillColor: (d) =>
d.severity === 'critical' ? [239, 68, 68, 180]
: d.severity === 'warning' ? [249, 115, 22, 170]
: [59, 130, 246, 160],
getLineColor: [255, 255, 255, 220],
lineWidthMinPixels: 1,
stroked: true,
pickable: true,
}),
);
}
return layers;
}, [track, mmsi, segments, segmentPaths]);
const tsList = track?.timestamps ?? [];
const startTs = tsList[0];
const endTs = tsList[tsList.length - 1];
return (
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Ship className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<div className="min-w-0">
<div className="text-[11px] font-bold text-heading truncate flex items-center gap-1.5">
{vesselName ?? mmsi}
<span className="text-[9px] text-hint font-mono">{mmsi}</span>
{segments.length > 0 && (
<Badge intent="critical" size="xs" className="font-normal">
{segments.length}
</Badge>
)}
</div>
<div className="flex items-center gap-1 text-[9px] text-hint">
<Clock className="w-2.5 h-2.5" />
<span>{startTs ? fmt(startTs) : '-'}</span>
<span></span>
<span>{endTs ? fmt(endTs) : '-'}</span>
<span className="ml-1 text-muted-foreground">· {track?.pointCount ?? 0} pts</span>
</div>
</div>
</div>
{onClose && (
<button type="button" onClick={onClose} aria-label="미니맵 닫기"
className="p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay shrink-0">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<div className="relative h-80 rounded overflow-hidden border border-slate-700/30">
<BaseMap ref={mapRef} height={320} interactive={true} zoom={7} />
{loading && (
<div className="absolute inset-0 bg-background/60 flex items-center justify-center">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
)}
{!loading && error && (
<div className="absolute inset-0 bg-background/80 flex items-center justify-center text-xs text-red-400 px-3 text-center">
{error}
</div>
)}
{!loading && !error && track && track.geometry.length < 2 && (
<div className="absolute inset-0 bg-background/60 flex items-center justify-center text-[11px] text-hint">
24 (AIS )
</div>
)}
</div>
{segments.length > 0 && (
<div className="flex items-start gap-x-3 gap-y-0.5 flex-wrap text-[9px] text-hint">
<span className="flex items-center gap-1">
<span className="inline-block w-4 h-[2px] bg-red-500" />
CRITICAL
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-4 h-[2px] bg-orange-500" />
WARNING
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-4 h-[2px] bg-blue-500" />
INFO
</span>
<span className="text-muted-foreground"> = AIS </span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-white/80 inline-block border border-slate-600" />
</span>
{segmentPaths.length < segments.length && (
<span className="text-muted-foreground">
· {segments.length - segmentPaths.length}
</span>
)}
</div>
)}
</CardContent>
</Card>
);
}