/** * 선박 궤적 미니맵 — 단일 MMSI 24h 항적 정적 표시. * fetchVesselTracks (signal-batch 프록시) 호출 → PathLayer 로 그림. */ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { Loader2, Ship, Clock, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; 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 { t: tc } = useTranslation('common'); const mapRef = useRef(null); const [track, setTrack] = useState(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({ 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 (
{vesselName ?? mmsi} {mmsi} {segments.length > 0 && ( 특이 구간 {segments.length} )}
{startTs ? fmt(startTs) : '-'} {endTs ? fmt(endTs) : '-'} · {track?.pointCount ?? 0} pts
{onClose && ( )}
{loading && (
)} {!loading && error && (
{error}
)} {!loading && !error && track && track.geometry.length < 2 && (
24시간 내 궤적 없음 (AIS 미수신)
)}
{segments.length > 0 && (
CRITICAL WARNING INFO 굵은 색 구간 = 판별 시간대 AIS 궤적 이벤트 기준점 {segmentPaths.length < segments.length && ( · 궤적 매칭 실패 {segments.length - segmentPaths.length}구간 )}
)}
); }