194 lines
7.2 KiB
TypeScript
194 lines
7.2 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
import type { ScatSegment } from './scatTypes';
|
||
|
||
interface ScatTimelineProps {
|
||
segments: ScatSegment[];
|
||
currentIdx: number;
|
||
onSeek: (idx: number) => void;
|
||
}
|
||
|
||
function ScatTimeline({ segments, currentIdx, onSeek }: ScatTimelineProps) {
|
||
const [playing, setPlaying] = useState(false);
|
||
const [speed, setSpeed] = useState(1);
|
||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
||
const total = Math.min(segments.length, 12);
|
||
const displaySegs = segments.slice(0, total);
|
||
const pct = ((currentIdx + 1) / total) * 100;
|
||
|
||
const stop = useCallback(() => {
|
||
if (intervalRef.current) {
|
||
clearInterval(intervalRef.current);
|
||
intervalRef.current = null;
|
||
}
|
||
setPlaying(false);
|
||
}, []);
|
||
|
||
const play = useCallback(() => {
|
||
stop();
|
||
setPlaying(true);
|
||
intervalRef.current = setInterval(() => {
|
||
onSeek(-1); // signal to advance
|
||
}, 800 / speed);
|
||
}, [speed, stop, onSeek]);
|
||
|
||
const togglePlay = () => {
|
||
if (playing) stop();
|
||
else play();
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (playing && intervalRef.current) {
|
||
clearInterval(intervalRef.current);
|
||
intervalRef.current = setInterval(() => {
|
||
onSeek(-1);
|
||
}, 800 / speed);
|
||
}
|
||
}, [speed, playing, onSeek]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||
};
|
||
}, []);
|
||
|
||
const cycleSpeed = () => {
|
||
const speeds = [1, 2, 4];
|
||
setSpeed((s) => speeds[(speeds.indexOf(s) + 1) % speeds.length]);
|
||
};
|
||
|
||
const doneCount = segments.filter((s) => s.status === '완료').length;
|
||
const progCount = segments.filter((s) => s.status === '진행중').length;
|
||
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0);
|
||
|
||
return (
|
||
<div className="absolute bottom-0 left-0 right-0 h-[72px] bg-[rgba(15,21,36,0.95)] backdrop-blur-2xl border-t border-stroke flex items-center px-5 gap-4 z-[40]">
|
||
{/* Controls */}
|
||
<div className="flex gap-1 flex-shrink-0">
|
||
<button
|
||
onClick={() => onSeek(0)}
|
||
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-sm"
|
||
>
|
||
⏮
|
||
</button>
|
||
<button
|
||
onClick={() => onSeek(Math.max(0, currentIdx - 1))}
|
||
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-sm"
|
||
>
|
||
◀
|
||
</button>
|
||
<button
|
||
onClick={togglePlay}
|
||
className={`w-[34px] h-[34px] rounded-sm border flex items-center justify-center cursor-pointer text-sm ${playing ? 'bg-color-success text-black border-status-green' : 'border-stroke bg-bg-card text-fg-sub hover:bg-bg-surface-hover'}`}
|
||
>
|
||
{playing ? '⏸' : '▶'}
|
||
</button>
|
||
<button
|
||
onClick={() => onSeek(Math.min(total - 1, currentIdx + 1))}
|
||
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-sm"
|
||
>
|
||
▶
|
||
</button>
|
||
<button
|
||
onClick={() => onSeek(total - 1)}
|
||
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-sm"
|
||
>
|
||
⏭
|
||
</button>
|
||
<div className="w-2" />
|
||
<button
|
||
onClick={cycleSpeed}
|
||
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-xs font-mono font-bold"
|
||
>
|
||
{speed}×
|
||
</button>
|
||
</div>
|
||
|
||
{/* Progress */}
|
||
<div className="flex-1 flex flex-col gap-1.5">
|
||
<div className="flex justify-between px-1">
|
||
{displaySegs.map((s, i) => (
|
||
<span
|
||
key={i}
|
||
className={`text-caption font-mono cursor-pointer ${i === currentIdx ? 'text-color-success font-semibold' : 'text-fg-disabled'}`}
|
||
onClick={() => onSeek(i)}
|
||
>
|
||
{s.code}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="relative h-6 flex items-center">
|
||
<div className="w-full h-1 bg-border rounded relative">
|
||
<div
|
||
className="absolute top-0 left-0 h-full rounded transition-all duration-300"
|
||
style={{
|
||
width: `${pct}%`,
|
||
background: 'linear-gradient(90deg, var(--color-success), #4ade80)',
|
||
}}
|
||
/>
|
||
{/* Markers */}
|
||
{displaySegs.map((s, i) => {
|
||
const x = ((i + 0.5) / total) * 100;
|
||
return (
|
||
<div
|
||
key={i}
|
||
className="absolute w-0.5 bg-border-light"
|
||
style={{ left: `${x}%`, top: -3, height: i % 3 === 0 ? 14 : 10 }}
|
||
>
|
||
{s.status === '완료' && (
|
||
<span
|
||
className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-caption cursor-pointer"
|
||
style={{ filter: 'drop-shadow(0 0 4px rgba(34,197,94,0.5))' }}
|
||
>
|
||
✅
|
||
</span>
|
||
)}
|
||
{s.status === '진행중' && (
|
||
<span className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-caption">
|
||
⏳
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{/* Thumb */}
|
||
<div
|
||
className="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-color-success border-[3px] border-bg-0 rounded-full cursor-grab shadow-[0_0_10px_rgba(34,197,94,0.4)] z-[2] transition-all duration-300"
|
||
style={{ left: `${pct}%`, transform: `translate(-50%, -50%)` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Info */}
|
||
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[210px]">
|
||
<span className="text-sm font-semibold text-color-success font-mono">
|
||
구간 {displaySegs[currentIdx]?.code || 'S-001'} / {total}개
|
||
</span>
|
||
<div className="flex gap-3.5">
|
||
<span className="flex items-center gap-1.5 text-label-2">
|
||
<span className="text-fg-sub font-korean">완료</span>
|
||
<span className="text-fg font-semibold font-mono text-color-success">
|
||
{doneCount}/{segments.length}
|
||
</span>
|
||
</span>
|
||
<span className="flex items-center gap-1.5 text-label-2">
|
||
<span className="text-fg-sub font-korean">진행중</span>
|
||
<span className="text-fg font-semibold font-mono text-color-warning">
|
||
{progCount}/{segments.length}
|
||
</span>
|
||
</span>
|
||
<span className="flex items-center gap-1.5 text-label-2">
|
||
<span className="text-fg-sub font-korean">총 해안선</span>
|
||
<span className="text-fg font-semibold font-mono">
|
||
{(totalLen / 1000).toFixed(1)} km
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default ScatTimeline;
|