React 19 + TypeScript + Vite + MapLibre 기반 해양 모니터링 대시보드. 선박 AIS, 항공기, CCTV, 위성, 해양 인프라 등 다중 레이어 지원. ESLint React Compiler 규칙 조정 및 lint 에러 수정 포함.
169 lines
5.3 KiB
TypeScript
169 lines
5.3 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
|
|
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 [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="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 style={{ 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="Custom range"
|
|
>
|
|
<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>FROM (KST)</span>
|
|
<input
|
|
type="datetime-local"
|
|
value={customStart}
|
|
onChange={e => setCustomStart(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>TO (KST)</span>
|
|
<input
|
|
type="datetime-local"
|
|
value={customEnd}
|
|
onChange={e => setCustomEnd(e.target.value)}
|
|
/>
|
|
</label>
|
|
<button className="range-apply-btn" onClick={handleCustomApply}>
|
|
APPLY
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|