kcg-ai-monitoring/frontend/src/features/detection/components/VesselMiniMap.tsx
htlee 8af693a2df refactor(i18n): alert/confirm/aria-label 하드코딩 한글 제거
공통 번역 리소스 확장:
- 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')} 추가
2026-04-16 16:32:37 +09:00

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>
);
}