공통 번역 리소스 확장:
- common.json 에 aria / error / dialog / success / message 네임스페이스 추가
- ko/en 양쪽 동일 구조 유지 (aria 36 키 + error 7 키 + dialog 4 키 + message 5 키)
alert/confirm 11건 → t() 치환:
- parent-inference: ParentReview / LabelSession / ParentExclusion
- admin: PermissionsPanel / UserRoleAssignDialog / AccessControl
aria-label 한글 40+건 → t() 치환:
- parent-inference (group_key/sub_cluster/정답 parent MMSI/스코프 필터 등)
- admin (역할 코드/이름, 알림 제목/내용, 시작일/종료일, 코드 검색, 대분류 필터, 수신 현황 기준일)
- detection (그룹 유형/해역 필터, 관심영역, 필터 설정/초기화, 멤버 수, 미니맵/재생 닫기)
- enforcement (확인/선박 상세/단속 등록/오탐 처리)
- vessel/statistics/ai-operations (조회 시작/종료 시각, 업로드 패널 닫기, 전송, 예시 URL 복사)
- 공통 컴포넌트 (SearchInput, NotificationBanner)
MainLayout 언어 토글:
- title 삼항분기 → t('message.switchToEnglish'/'switchToKorean')
- aria-label="페이지 내 검색" → t('aria.searchInPage')
- 토글 버튼 자체에 aria-label={t('aria.languageToggle')} 추가
262 lines
10 KiB
TypeScript
262 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 { 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<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={tc('aria.miniMapClose')}
|
|
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>
|
|
);
|
|
}
|