kcg-monitoring/frontend/src/components/ReplayControls.tsx
htlee 2534faa488 feat: 프론트엔드 모노레포 이관 + signal-batch 연동 + Tailwind/i18n/테마 전환
- frontend/ 폴더로 프론트엔드 전체 이관
- signal-batch API 연동 (한국 선박 위치 데이터)
- Tailwind CSS 4 + CSS 변수 테마 토큰 (dark/light)
- i18next 다국어 (ko/en) 인프라 + 28개 컴포넌트 적용
- 레이어 패널 트리 구조 재설계 (카테고리별 온/오프, 범례)
- Google OAuth 로그인 화면 + DEV LOGIN 우회
- 외부 API CORS 프록시 전환 (Airplanes.live, OpenSky, CelesTrak)
- ShipLayer 이미지 탭 전환 (signal-batch / MarineTraffic)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:54:41 +09:00

171 lines
5.4 KiB
TypeScript

import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
interface Props {
isPlaying: boolean;
speed: number;
startTime: number;
endTime: number;
onPlay: () => void;
onPause: () => void;
onReset: () => void;
onSpeedChange: (speed: number) => void;
onRangeChange: (start: number, end: number) => void;
}
const SPEEDS = [1, 2, 4, 8, 16];
// Preset ranges relative to T0 (main strike moment)
const T0 = new Date('2026-03-01T12:01:00Z').getTime();
const HOUR = 3600_000;
const PRESETS = [
{ label: '24H', start: T0 - 12 * HOUR, end: T0 + 12 * HOUR },
{ label: '12H', start: T0 - 6 * HOUR, end: T0 + 6 * HOUR },
{ label: '6H', start: T0 - 3 * HOUR, end: T0 + 3 * HOUR },
{ label: '2H', start: T0 - HOUR, end: T0 + HOUR },
{ label: '30M', start: T0 - 15 * 60_000, end: T0 + 15 * 60_000 },
];
const KST_OFFSET = 9 * 3600_000; // KST = UTC+9
function toKSTInput(ts: number): string {
// Format as datetime-local value in KST: YYYY-MM-DDTHH:MM
const d = new Date(ts + KST_OFFSET);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
function fromKSTInput(val: string): number {
// Parse datetime-local as KST → convert to UTC
return new Date(val + 'Z').getTime() - KST_OFFSET;
}
export function ReplayControls({
isPlaying,
speed,
startTime,
endTime,
onPlay,
onPause,
onReset,
onSpeedChange,
onRangeChange,
}: Props) {
const { t } = useTranslation();
const [showPicker, setShowPicker] = useState(false);
const [customStart, setCustomStart] = useState(toKSTInput(startTime));
const [customEnd, setCustomEnd] = useState(toKSTInput(endTime));
const handlePreset = useCallback((preset: typeof PRESETS[number]) => {
onRangeChange(preset.start, preset.end);
setCustomStart(toKSTInput(preset.start));
setCustomEnd(toKSTInput(preset.end));
}, [onRangeChange]);
const handleCustomApply = useCallback(() => {
const s = fromKSTInput(customStart);
const e = fromKSTInput(customEnd);
if (s < e) {
onRangeChange(s, e);
setShowPicker(false);
}
}, [customStart, customEnd, onRangeChange]);
// Find which preset is active
const activePreset = PRESETS.find(p => p.start === startTime && p.end === endTime);
return (
<div className="replay-controls">
{/* Left: transport controls */}
<button className="ctrl-btn" onClick={onReset} title={t('controls.reset')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 4v6h6" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
</button>
<button className="ctrl-btn play-btn" onClick={isPlaying ? onPause : onPlay}>
{isPlaying ? (
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</svg>
) : (
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5,3 19,12 5,21" />
</svg>
)}
</button>
<div className="speed-controls">
{SPEEDS.map(s => (
<button
key={s}
className={`speed-btn ${speed === s ? 'active' : ''}`}
onClick={() => onSpeedChange(s)}
>
{s}x
</button>
))}
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Right: range presets + custom picker */}
<div className="range-controls">
<div className="range-presets">
{PRESETS.map(p => (
<button
key={p.label}
className={`range-btn ${activePreset === p ? 'active' : ''}`}
onClick={() => handlePreset(p)}
>
{p.label}
</button>
))}
<button
className={`range-btn custom-btn ${showPicker ? 'active' : ''}`}
onClick={() => setShowPicker(!showPicker)}
title={t('controls.customRange')}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</button>
</div>
{showPicker && (
<div className="range-picker">
<div className="range-picker-row">
<label>
<span>{t('controls.from')}</span>
<input
type="datetime-local"
value={customStart}
onChange={e => setCustomStart(e.target.value)}
/>
</label>
<label>
<span>{t('controls.to')}</span>
<input
type="datetime-local"
value={customEnd}
onChange={e => setCustomEnd(e.target.value)}
/>
</label>
<button className="range-apply-btn" onClick={handleCustomApply}>
{t('controls.apply')}
</button>
</div>
</div>
)}
</div>
</div>
);
}