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회 연속 감지 · 카테고리 뱃지 · 설명
260 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|