kcg-ai-monitoring/frontend/src/features/detection/components/GearReplayController.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

182 lines
6.2 KiB
TypeScript

/**
* GearReplayController — 어구 그룹 24시간 궤적 재생 컨트롤러
*
* 맵 위에 absolute 포지셔닝으로 표시.
* Zustand subscribe 패턴으로 DOM 직접 업데이트 → 재생 중 React re-render 없음.
*/
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@shared/components/ui/button';
import { useGearReplayStore } from '@stores/gearReplayStore';
import { Play, Pause, X } from 'lucide-react';
interface GearReplayControllerProps {
onClose: () => void;
}
const SPEED_OPTIONS = [1, 2, 5, 10] as const;
type SpeedOption = (typeof SPEED_OPTIONS)[number];
function formatEpochTime(epochMs: number): string {
if (epochMs === 0) return '--:--';
const d = new Date(epochMs);
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 mm = String(d.getMinutes()).padStart(2, '0');
return `${MM}/${dd} ${hh}:${mm}`;
}
export function GearReplayController({ onClose }: GearReplayControllerProps) {
const { t: tc } = useTranslation('common');
const play = useGearReplayStore((s) => s.play);
const pause = useGearReplayStore((s) => s.pause);
const seek = useGearReplayStore((s) => s.seek);
const setSpeed = useGearReplayStore((s) => s.setSpeed);
const isPlaying = useGearReplayStore((s) => s.isPlaying);
const playbackSpeed = useGearReplayStore((s) => s.playbackSpeed);
const startTime = useGearReplayStore((s) => s.startTime);
const endTime = useGearReplayStore((s) => s.endTime);
const snapshotRanges = useGearReplayStore((s) => s.snapshotRanges);
const dataStartTime = useGearReplayStore((s) => s.dataStartTime);
const dataEndTime = useGearReplayStore((s) => s.dataEndTime);
// DOM refs for direct updates — no React state during playback
const progressBarRef = useRef<HTMLDivElement>(null);
const timeLabelRef = useRef<HTMLSpanElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
// Subscribe to currentTime changes and update DOM directly
useEffect(() => {
const unsubscribe = useGearReplayStore.subscribe(
(s) => s.currentTime,
(currentTime) => {
const duration = endTime - startTime;
const pct = duration > 0 ? ((currentTime - startTime) / duration) * 100 : 0;
if (progressBarRef.current) {
progressBarRef.current.style.width = `${Math.min(100, pct)}%`;
}
if (timeLabelRef.current) {
timeLabelRef.current.textContent = formatEpochTime(currentTime);
}
},
);
return unsubscribe;
}, [startTime, endTime]);
// Handle click on track to seek
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!trackRef.current) return;
const rect = trackRef.current.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
seek(startTime + ratio * (endTime - startTime));
};
const handlePlayPause = () => {
if (isPlaying) {
pause();
} else {
play();
}
};
const duration = endTime - startTime;
const initialPct =
duration > 0
? ((useGearReplayStore.getState().currentTime - startTime) / duration) * 100
: 0;
return (
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 z-[1001] w-full max-w-[520px] px-3">
<div className="bg-background/95 backdrop-blur-sm border border-border rounded-lg px-3 py-2 shadow-lg flex items-center gap-2">
{/* Play / Pause */}
<Button
type="button"
variant="ghost"
size="sm"
aria-label={isPlaying ? '일시정지' : '재생'}
onClick={handlePlayPause}
className="shrink-0 p-1"
icon={
isPlaying ? (
<Pause className="w-4 h-4 text-heading" />
) : (
<Play className="w-4 h-4 text-heading" />
)
}
/>
{/* Speed selector */}
<div className="flex items-center gap-1 shrink-0">
{SPEED_OPTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => setSpeed(s)}
className={[
'text-xs px-1.5 py-0.5 rounded font-mono transition-colors',
playbackSpeed === s
? 'bg-primary text-on-vivid font-bold'
: 'text-hint hover:text-label hover:bg-surface-raised',
].join(' ')}
>
{s}x
</button>
))}
</div>
{/* Start time */}
<span className="text-[9px] font-mono text-hint shrink-0">{formatEpochTime(startTime)}</span>
{/* Progress track */}
<div
ref={trackRef}
className="flex-1 h-2 bg-surface-raised rounded-full cursor-pointer relative overflow-hidden min-w-[80px]"
onClick={handleTrackClick}
role="slider"
aria-label={tc('aria.replayPosition')}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(initialPct)}
>
{/* 스냅샷 틱마크 */}
{snapshotRanges.map((r, i) => (
<div
key={i}
className="absolute top-0 bottom-0 w-px bg-amber-400/50"
style={{ left: `${r * 100}%` }}
/>
))}
<div
ref={progressBarRef}
className="absolute inset-y-0 left-0 bg-primary rounded-full transition-none"
style={{ width: `${Math.min(100, Math.max(0, initialPct))}%` }}
/>
</div>
{/* End time */}
<span className="text-[9px] font-mono text-hint shrink-0">{formatEpochTime(endTime)}</span>
{/* Current time label */}
<span
ref={timeLabelRef}
className="text-[10px] font-mono text-heading shrink-0 w-[75px] text-center font-bold"
>
{formatEpochTime(useGearReplayStore.getState().currentTime)}
</span>
{/* Close */}
<button
type="button"
aria-label={tc('aria.replayClose')}
onClick={onClose}
className="shrink-0 p-1 hover:bg-surface-raised rounded"
>
<X className="w-3.5 h-3.5 text-hint" />
</button>
</div>
</div>
);
}