187 lines
7.1 KiB
TypeScript
187 lines
7.1 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-react'
|
||
import { usePlaybackStore, REPLAY_TICKS, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
|
||
import { useSituationStore } from '@/store/situationStore'
|
||
import { NewsTicker } from './NewsTicker'
|
||
import { config } from '@/config'
|
||
|
||
/** 冲突开始时间:2月28日凌晨 03:00(本地时间) */
|
||
const CONFLICT_START = new Date(2026, 1, 28, 3, 0, 0, 0)
|
||
|
||
function formatTick(iso: string): string {
|
||
const d = new Date(iso)
|
||
return d.toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: false,
|
||
})
|
||
}
|
||
|
||
function getConflictDuration(toTime: Date): { days: number; hours: number } {
|
||
const diffMs = toTime.getTime() - CONFLICT_START.getTime()
|
||
if (diffMs <= 0) return { days: 0, hours: 0 }
|
||
const days = Math.floor(diffMs / (24 * 60 * 60 * 1000))
|
||
const hours = Math.floor((diffMs % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000))
|
||
return { days, hours }
|
||
}
|
||
|
||
export function TimelinePanel() {
|
||
const situation = useSituationStore((s) => s.situation)
|
||
const [now, setNow] = useState(() => new Date())
|
||
const {
|
||
isReplayMode,
|
||
playbackTime,
|
||
isPlaying,
|
||
speedSecPerTick,
|
||
setReplayMode,
|
||
setPlaybackTime,
|
||
setIsPlaying,
|
||
stepForward,
|
||
stepBack,
|
||
setSpeed,
|
||
} = usePlaybackStore()
|
||
|
||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||
|
||
useEffect(() => {
|
||
const t = setInterval(() => setNow(new Date()), 1000)
|
||
return () => clearInterval(t)
|
||
}, [])
|
||
|
||
const toTime = isReplayMode ? new Date(playbackTime) : now
|
||
const conflictDuration = getConflictDuration(toTime)
|
||
|
||
useEffect(() => {
|
||
if (!isPlaying || !isReplayMode) {
|
||
if (timerRef.current) {
|
||
clearInterval(timerRef.current)
|
||
timerRef.current = null
|
||
}
|
||
return
|
||
}
|
||
timerRef.current = setInterval(() => {
|
||
const current = usePlaybackStore.getState().playbackTime
|
||
const i = REPLAY_TICKS.indexOf(current)
|
||
if (i >= REPLAY_TICKS.length - 1) {
|
||
setIsPlaying(false)
|
||
return
|
||
}
|
||
setPlaybackTime(REPLAY_TICKS[i + 1])
|
||
}, speedSecPerTick * 1000)
|
||
return () => {
|
||
if (timerRef.current) clearInterval(timerRef.current)
|
||
}
|
||
}, [isPlaying, isReplayMode, speedSecPerTick, setPlaybackTime, setIsPlaying])
|
||
|
||
const index = REPLAY_TICKS.indexOf(playbackTime)
|
||
const value = index >= 0 ? index : REPLAY_TICKS.length - 1
|
||
|
||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const i = parseInt(e.target.value, 10)
|
||
setPlaybackTime(REPLAY_TICKS[i])
|
||
}
|
||
|
||
return (
|
||
<div className="relative shrink-0 border-b border-military-border bg-military-panel/95 px-3 py-2">
|
||
{!isReplayMode && (
|
||
<div
|
||
className="pointer-events-none absolute inset-0 flex items-center justify-center px-2"
|
||
aria-hidden
|
||
>
|
||
<span className="tabular-nums font-bold text-red-500">
|
||
冲突已持续 {conflictDuration.days} 天 {conflictDuration.hours} 小时
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div className="relative flex flex-wrap items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setReplayMode(!isReplayMode)}
|
||
className={`flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||
isReplayMode
|
||
? 'bg-military-accent/30 text-military-accent'
|
||
: 'bg-military-border/50 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary'
|
||
}`}
|
||
>
|
||
<History className="h-3.5 w-3.5" />
|
||
数据回放
|
||
</button>
|
||
|
||
{!isReplayMode && config.showNewsTicker && (
|
||
<div className="min-w-0 flex-1">
|
||
<NewsTicker
|
||
updates={situation.recentUpdates}
|
||
conflictEvents={situation.conflictEvents}
|
||
className="!border-0 !bg-transparent !py-0 !px-0"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{isReplayMode && (
|
||
<>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={stepBack}
|
||
disabled={index <= 0}
|
||
className="rounded p-1 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-military-text-secondary"
|
||
title="上一步"
|
||
>
|
||
<SkipBack className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsPlaying(!isPlaying)}
|
||
className="rounded p-1 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary"
|
||
title={isPlaying ? '暂停' : '播放'}
|
||
>
|
||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={stepForward}
|
||
disabled={index >= REPLAY_TICKS.length - 1}
|
||
className="rounded p-1 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-military-text-secondary"
|
||
title="下一步"
|
||
>
|
||
<SkipForward className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex min-w-0 flex-1 items-center gap-2 lg:min-w-[320px]">
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={REPLAY_TICKS.length - 1}
|
||
value={value}
|
||
onChange={handleSliderChange}
|
||
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-military-border [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-military-accent"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 text-[11px] tabular-nums text-military-text-secondary">
|
||
<span>{formatTick(REPLAY_START)}</span>
|
||
<span className="font-medium text-military-accent">{formatTick(playbackTime)}</span>
|
||
<span>{formatTick(REPLAY_END)}</span>
|
||
</div>
|
||
|
||
<select
|
||
value={speedSecPerTick}
|
||
onChange={(e) => setSpeed(Number(e.target.value))}
|
||
className="rounded border border-military-border bg-military-dark/80 px-2 py-1 text-[11px] text-military-text-secondary focus:border-military-accent focus:outline-none"
|
||
>
|
||
<option value={0.5}>0.5 秒/刻度</option>
|
||
<option value={1}>1 秒/刻度</option>
|
||
<option value={2}>2 秒/刻度</option>
|
||
<option value={3}>3 秒/刻度</option>
|
||
<option value={5}>5 秒/刻度</option>
|
||
</select>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|