Files
usa/src/components/TimelinePanel.tsx
2026-03-06 11:41:09 +08:00

221 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-react'
import { usePlaybackStore, getTicks, REPLAY_START, REPLAY_END, type ReplayScale } 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,
replayScale,
isPlaying,
speedSecPerTick,
setReplayMode,
setPlaybackTime,
setReplayScale,
setIsPlaying,
stepForward,
stepBack,
setSpeed,
} = usePlaybackStore()
const replayTicks = getTicks(replayScale)
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 { playbackTime: current, replayScale: scale } = usePlaybackStore.getState()
const ticks = getTicks(scale)
const i = ticks.indexOf(current)
if (i >= ticks.length - 1) {
setIsPlaying(false)
return
}
setPlaybackTime(ticks[i + 1])
}, speedSecPerTick * 1000)
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [isPlaying, isReplayMode, speedSecPerTick, setPlaybackTime, setIsPlaying])
const index = replayTicks.indexOf(playbackTime)
const value = index >= 0 ? index : replayTicks.length - 1
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const i = parseInt(e.target.value, 10)
setPlaybackTime(replayTicks[i])
}
const scaleLabels: Record<ReplayScale, string> = { '30m': '30分钟', '1h': '1小时', '1d': '1天' }
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 >= replayTicks.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 items-center gap-1 text-[11px] text-military-text-secondary">
<span></span>
<select
value={replayScale}
onChange={(e) => setReplayScale(e.target.value as ReplayScale)}
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"
title="回放刻度"
>
{(['30m', '1h', '1d'] as const).map((s) => (
<option key={s} value={s}>{scaleLabels[s]}</option>
))}
</select>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 lg:min-w-[320px]">
<input
type="range"
min={0}
max={replayTicks.length - 1}
value={value}
onChange={handleSliderChange}
className="h-1.5 w-full 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"
/>
{/* 按刻度划分的时间轴:均匀取约 56 个刻度标签 */}
<div className="flex justify-between text-[10px] tabular-nums text-military-text-secondary">
{replayTicks.length <= 1 ? (
<span>{formatTick(replayTicks[0] ?? REPLAY_START)}</span>
) : (
(() => {
const n = replayTicks.length - 1
const maxLabels = 6
const step = Math.max(1, Math.floor(n / (maxLabels - 1)))
const indices = [0, ...Array.from({ length: maxLabels - 2 }, (_, j) => Math.min((j + 1) * step, n)), n]
return [...new Set(indices)].map((i) => (
<span key={i}>{formatTick(replayTicks[i])}</span>
))
})()
)}
</div>
</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>
</>
)}
<span className="ml-auto text-[10px] text-military-text-secondary shrink-0">
</span>
</div>
</div>
)
}