공통 번역 리소스 확장:
- 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')} 추가
182 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|