fix:优化数据来源
This commit is contained in:
89
src/components/EventTimelinePanel.tsx
Normal file
89
src/components/EventTimelinePanel.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { SituationUpdate, ConflictEvent } from '@/data/mockData'
|
||||
import { History } from 'lucide-react'
|
||||
|
||||
interface EventTimelinePanelProps {
|
||||
updates: SituationUpdate[]
|
||||
conflictEvents?: ConflictEvent[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const CAT_LABELS: Record<string, string> = {
|
||||
deployment: '部署',
|
||||
alert: '警报',
|
||||
intel: '情报',
|
||||
diplomatic: '外交',
|
||||
other: '其他',
|
||||
}
|
||||
function formatTime(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 })
|
||||
}
|
||||
|
||||
type TimelineItem = {
|
||||
id: string
|
||||
summary: string
|
||||
timestamp: string
|
||||
source: 'gdelt' | 'rss'
|
||||
category?: string
|
||||
severity?: string
|
||||
}
|
||||
|
||||
export function EventTimelinePanel({ updates = [], conflictEvents = [], className = '' }: EventTimelinePanelProps) {
|
||||
// 合并 GDELT + RSS,按时间倒序(最新在前)
|
||||
const merged: TimelineItem[] = [
|
||||
...(conflictEvents || []).map((e) => ({
|
||||
id: e.event_id,
|
||||
summary: e.title,
|
||||
timestamp: e.event_time,
|
||||
source: 'gdelt' as const,
|
||||
category: 'alert',
|
||||
severity: e.impact_score >= 7 ? 'high' : e.impact_score >= 4 ? 'medium' : 'low',
|
||||
})),
|
||||
...(updates || []).map((u) => ({
|
||||
id: u.id,
|
||||
summary: u.summary,
|
||||
timestamp: u.timestamp,
|
||||
source: 'rss' as const,
|
||||
category: u.category,
|
||||
severity: u.severity,
|
||||
})),
|
||||
]
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, 6)
|
||||
|
||||
return (
|
||||
<div className={`flex min-w-0 max-w-[280px] shrink flex-col overflow-hidden rounded border border-military-border bg-military-panel/80 ${className}`}>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-military-border px-3 py-1.5">
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-military-text-secondary">
|
||||
<History className="h-3.5 w-3.5 shrink-0 text-military-accent" />
|
||||
事件脉络
|
||||
</span>
|
||||
<span className="text-[8px] text-military-text-secondary/80">GDELT · Reuters · BBC · Al Jazeera · NYT</span>
|
||||
</div>
|
||||
<div className="min-h-0 max-h-[140px] flex-1 overflow-y-auto overflow-x-hidden px-2 py-1">
|
||||
{merged.length === 0 ? (
|
||||
<p className="py-4 text-center text-[11px] text-military-text-secondary">暂无事件</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{merged.map((item) => (
|
||||
<li key={item.id} className="flex gap-2 border-b border-military-border/50 pb-2 last:border-0 last:pb-0">
|
||||
<span className="shrink-0 pt-0.5">
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${item.source === 'gdelt' ? 'bg-cyan-500' : 'bg-amber-500'}`} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 text-right">
|
||||
<p className="text-[11px] leading-tight text-military-text-primary line-clamp-2">{item.summary}</p>
|
||||
<span className="mt-0.5 flex items-center justify-end gap-1 text-[9px] text-military-text-secondary">
|
||||
{formatTime(item.timestamp)}
|
||||
<span className="text-military-text-secondary/60">
|
||||
{item.source === 'gdelt' ? 'GDELT' : CAT_LABELS[item.category ?? ''] ?? '新闻'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
src/components/RecentUpdatesPanel.tsx
Normal file
71
src/components/RecentUpdatesPanel.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { SituationUpdate, ConflictEvent } from '@/data/mockData'
|
||||
import { Newspaper, AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface RecentUpdatesPanelProps {
|
||||
updates: SituationUpdate[]
|
||||
conflictEvents?: ConflictEvent[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const CAT_LABELS: Record<string, string> = {
|
||||
deployment: '部署',
|
||||
alert: '警报',
|
||||
intel: '情报',
|
||||
diplomatic: '外交',
|
||||
other: '其他',
|
||||
}
|
||||
const SEV_COLORS: Record<string, string> = {
|
||||
low: 'text-military-text-secondary',
|
||||
medium: 'text-amber-400',
|
||||
high: 'text-orange-500',
|
||||
critical: 'text-red-500',
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function RecentUpdatesPanel({ updates, conflictEvents = [], className = '' }: RecentUpdatesPanelProps) {
|
||||
// 优先展示 GDELT 冲突事件(最新 10 条),无则用 updates
|
||||
const fromConflict = (conflictEvents || [])
|
||||
.slice(0, 10)
|
||||
.map((e) => ({ id: e.event_id, summary: e.title, timestamp: e.event_time, category: 'alert' as const, severity: (e.impact_score >= 7 ? 'high' : e.impact_score >= 4 ? 'medium' : 'low') as const }))
|
||||
const list = fromConflict.length > 0 ? fromConflict : (updates || []).slice(0, 8)
|
||||
|
||||
return (
|
||||
<div className={`flex min-w-0 flex-1 flex-col overflow-hidden rounded border border-military-border bg-military-panel/80 ${className}`}>
|
||||
<div className="flex shrink-0 items-center gap-1.5 border-b border-military-border px-3 py-1.5">
|
||||
<Newspaper className="h-3.5 w-3.5 shrink-0 text-military-accent" />
|
||||
<span className="truncate text-[10px] font-semibold uppercase tracking-wider text-military-text-secondary">
|
||||
近期动态
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2 py-1">
|
||||
{list.length === 0 ? (
|
||||
<p className="py-4 text-center text-[11px] text-military-text-secondary">暂无动态</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{list.map((u) => (
|
||||
<li key={u.id} className="flex gap-2 border-b border-military-border/50 pb-2 last:border-0 last:pb-0">
|
||||
<span className={`shrink-0 text-[9px] ${SEV_COLORS[u.severity] ?? 'text-military-text-secondary'}`}>
|
||||
{u.severity === 'critical' && <AlertTriangle className="inline h-2.5 w-2.5" />}
|
||||
{CAT_LABELS[u.category] ?? u.category}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 text-right">
|
||||
<p className="text-[11px] leading-tight text-military-text-primary line-clamp-2">{u.summary}</p>
|
||||
<span className="mt-0.5 block text-[9px] text-military-text-secondary">{formatTime(u.timestamp)}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
src/components/TimelinePanel.tsx
Normal file
142
src/components/TimelinePanel.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-react'
|
||||
import { usePlaybackStore, REPLAY_TICKS, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
export function TimelinePanel() {
|
||||
const {
|
||||
isReplayMode,
|
||||
playbackTime,
|
||||
isPlaying,
|
||||
speedSecPerTick,
|
||||
setReplayMode,
|
||||
setPlaybackTime,
|
||||
setIsPlaying,
|
||||
stepForward,
|
||||
stepBack,
|
||||
setSpeed,
|
||||
} = usePlaybackStore()
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
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="shrink-0 border-b border-military-border bg-military-panel/95 px-3 py-2">
|
||||
<div className="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 && (
|
||||
<>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
143
src/hooks/useReplaySituation.ts
Normal file
143
src/hooks/useReplaySituation.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { MilitarySituation } from '@/data/mockData'
|
||||
import { useSituationStore } from '@/store/situationStore'
|
||||
import { usePlaybackStore } from '@/store/playbackStore'
|
||||
|
||||
/** 将系列时间映射到回放日 (2026-03-01) 以便按当天时刻插值 */
|
||||
function toReplayDay(iso: string, baseDay: string): string {
|
||||
const d = new Date(iso)
|
||||
const [y, m, day] = baseDay.slice(0, 10).split('-').map(Number)
|
||||
return new Date(y, (m || 1) - 1, day || 1, d.getUTCHours(), d.getUTCMinutes(), 0, 0).toISOString()
|
||||
}
|
||||
|
||||
function interpolateAt(
|
||||
series: { time: string; value: number }[],
|
||||
at: string,
|
||||
baseDay = '2026-03-01'
|
||||
): number {
|
||||
if (series.length === 0) return 0
|
||||
const t = new Date(at).getTime()
|
||||
const mapped = series.map((p) => ({
|
||||
time: toReplayDay(p.time, baseDay),
|
||||
value: p.value,
|
||||
}))
|
||||
const sorted = [...mapped].sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime())
|
||||
const before = sorted.filter((p) => new Date(p.time).getTime() <= t)
|
||||
const after = sorted.filter((p) => new Date(p.time).getTime() > t)
|
||||
if (before.length === 0) return sorted[0].value
|
||||
if (after.length === 0) return sorted[sorted.length - 1].value
|
||||
const a = before[before.length - 1]
|
||||
const b = after[0]
|
||||
const ta = new Date(a.time).getTime()
|
||||
const tb = new Date(b.time).getTime()
|
||||
const f = tb === ta ? 1 : (t - ta) / (tb - ta)
|
||||
return a.value + f * (b.value - a.value)
|
||||
}
|
||||
|
||||
function linearProgress(start: string, end: string, at: string): number {
|
||||
const ts = new Date(start).getTime()
|
||||
const te = new Date(end).getTime()
|
||||
const ta = new Date(at).getTime()
|
||||
if (ta <= ts) return 0
|
||||
if (ta >= te) return 1
|
||||
return (ta - ts) / (te - ts)
|
||||
}
|
||||
|
||||
/** 根据回放时刻派生态势数据 */
|
||||
export function useReplaySituation(): MilitarySituation {
|
||||
const situation = useSituationStore((s) => s.situation)
|
||||
const { isReplayMode, playbackTime } = usePlaybackStore()
|
||||
|
||||
return useMemo(() => {
|
||||
if (!isReplayMode) return situation
|
||||
|
||||
const progress = linearProgress('2026-03-01T02:00:00.000Z', '2026-03-01T11:45:00.000Z', playbackTime)
|
||||
|
||||
// 华尔街趋势、反击情绪:按时间插值
|
||||
const wsValue = interpolateAt(situation.usForces.wallStreetInvestmentTrend, playbackTime)
|
||||
const retValue = interpolateAt(situation.iranForces.retaliationSentimentHistory, playbackTime)
|
||||
|
||||
// 战斗损失:从 0 线性增长到当前值
|
||||
const lerp = (a: number, b: number) => Math.round(a + progress * (b - a))
|
||||
const usLoss = situation.usForces.combatLosses
|
||||
const irLoss = situation.iranForces.combatLosses
|
||||
const civUs = usLoss.civilianCasualties ?? { killed: 0, wounded: 0 }
|
||||
const civIr = irLoss.civilianCasualties ?? { killed: 0, wounded: 0 }
|
||||
const usLossesAt = {
|
||||
bases: {
|
||||
destroyed: lerp(0, usLoss.bases.destroyed),
|
||||
damaged: lerp(0, usLoss.bases.damaged),
|
||||
},
|
||||
personnelCasualties: {
|
||||
killed: lerp(0, usLoss.personnelCasualties.killed),
|
||||
wounded: lerp(0, usLoss.personnelCasualties.wounded),
|
||||
},
|
||||
civilianCasualties: { killed: lerp(0, civUs.killed), wounded: lerp(0, civUs.wounded) },
|
||||
aircraft: lerp(0, usLoss.aircraft),
|
||||
warships: lerp(0, usLoss.warships),
|
||||
armor: lerp(0, usLoss.armor),
|
||||
vehicles: lerp(0, usLoss.vehicles),
|
||||
}
|
||||
const irLossesAt = {
|
||||
bases: {
|
||||
destroyed: lerp(0, irLoss.bases.destroyed),
|
||||
damaged: lerp(0, irLoss.bases.damaged),
|
||||
},
|
||||
personnelCasualties: {
|
||||
killed: lerp(0, irLoss.personnelCasualties.killed),
|
||||
wounded: lerp(0, irLoss.personnelCasualties.wounded),
|
||||
},
|
||||
civilianCasualties: { killed: lerp(0, civIr.killed), wounded: lerp(0, civIr.wounded) },
|
||||
aircraft: lerp(0, irLoss.aircraft),
|
||||
warships: lerp(0, irLoss.warships),
|
||||
armor: lerp(0, irLoss.armor),
|
||||
vehicles: lerp(0, irLoss.vehicles),
|
||||
}
|
||||
|
||||
// 被袭基地:按 damage_level 排序,高损毁先出现;根据 progress 决定显示哪些为 attacked
|
||||
const usLocs = situation.usForces.keyLocations || []
|
||||
const attackedBases = usLocs
|
||||
.filter((loc) => loc.status === 'attacked')
|
||||
.sort((a, b) => (b.damage_level ?? 0) - (a.damage_level ?? 0))
|
||||
const totalAttacked = attackedBases.length
|
||||
const shownAttackedCount = Math.round(progress * totalAttacked)
|
||||
const attackedNames = new Set(
|
||||
attackedBases.slice(0, shownAttackedCount).map((l) => l.name)
|
||||
)
|
||||
|
||||
const usLocsAt = usLocs.map((loc) => {
|
||||
if (loc.status === 'attacked' && !attackedNames.has(loc.name)) {
|
||||
return { ...loc, status: 'operational' as const }
|
||||
}
|
||||
return { ...loc }
|
||||
})
|
||||
|
||||
return {
|
||||
...situation,
|
||||
lastUpdated: playbackTime,
|
||||
usForces: {
|
||||
...situation.usForces,
|
||||
keyLocations: usLocsAt,
|
||||
combatLosses: usLossesAt,
|
||||
wallStreetInvestmentTrend: [
|
||||
...situation.usForces.wallStreetInvestmentTrend.filter((p) => new Date(p.time).getTime() <= new Date(playbackTime).getTime()),
|
||||
{ time: playbackTime, value: wsValue },
|
||||
].slice(-20),
|
||||
},
|
||||
iranForces: {
|
||||
...situation.iranForces,
|
||||
combatLosses: irLossesAt,
|
||||
retaliationSentiment: retValue,
|
||||
retaliationSentimentHistory: [
|
||||
...situation.iranForces.retaliationSentimentHistory.filter((p) => new Date(p.time).getTime() <= new Date(playbackTime).getTime()),
|
||||
{ time: playbackTime, value: retValue },
|
||||
].slice(-20),
|
||||
},
|
||||
recentUpdates: (situation.recentUpdates || []).filter(
|
||||
(u) => new Date(u.timestamp).getTime() <= new Date(playbackTime).getTime()
|
||||
),
|
||||
conflictEvents: situation.conflictEvents || [],
|
||||
conflictStats: situation.conflictStats || { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
|
||||
}
|
||||
}, [situation, isReplayMode, playbackTime])
|
||||
}
|
||||
80
src/store/playbackStore.ts
Normal file
80
src/store/playbackStore.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
const REPLAY_DAY = '2026-03-01'
|
||||
const TICK_MS = 30 * 60 * 1000 // 30 minutes
|
||||
|
||||
export const REPLAY_START = `${REPLAY_DAY}T00:00:00.000Z`
|
||||
export const REPLAY_END = `${REPLAY_DAY}T23:30:00.000Z`
|
||||
|
||||
function parseTime(iso: string): number {
|
||||
return new Date(iso).getTime()
|
||||
}
|
||||
|
||||
export function getTicks(): string[] {
|
||||
const ticks: string[] = []
|
||||
let t = parseTime(REPLAY_START)
|
||||
const end = parseTime(REPLAY_END)
|
||||
while (t <= end) {
|
||||
ticks.push(new Date(t).toISOString())
|
||||
t += TICK_MS
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
|
||||
export const REPLAY_TICKS = getTicks()
|
||||
|
||||
export interface PlaybackState {
|
||||
/** 是否开启回放模式 */
|
||||
isReplayMode: boolean
|
||||
/** 当前回放时刻 (ISO) */
|
||||
playbackTime: string
|
||||
/** 是否正在自动播放 */
|
||||
isPlaying: boolean
|
||||
/** 播放速度 (秒/刻度) */
|
||||
speedSecPerTick: number
|
||||
setReplayMode: (v: boolean) => void
|
||||
setPlaybackTime: (iso: string) => void
|
||||
setIsPlaying: (v: boolean) => void
|
||||
stepForward: () => void
|
||||
stepBack: () => void
|
||||
setSpeed: (sec: number) => void
|
||||
}
|
||||
|
||||
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
isReplayMode: false,
|
||||
playbackTime: REPLAY_END,
|
||||
isPlaying: false,
|
||||
speedSecPerTick: 2,
|
||||
|
||||
setReplayMode: (v) => set({ isReplayMode: v, isPlaying: false }),
|
||||
|
||||
setPlaybackTime: (iso) => {
|
||||
const ticks = REPLAY_TICKS
|
||||
if (ticks.includes(iso)) {
|
||||
set({ playbackTime: iso })
|
||||
return
|
||||
}
|
||||
const idx = ticks.findIndex((t) => t >= iso)
|
||||
const clamp = Math.max(0, Math.min(idx < 0 ? ticks.length - 1 : idx, ticks.length - 1))
|
||||
set({ playbackTime: ticks[clamp] })
|
||||
},
|
||||
|
||||
setIsPlaying: (v) => set({ isPlaying: v }),
|
||||
|
||||
stepForward: () => {
|
||||
const { playbackTime } = get()
|
||||
const ticks = REPLAY_TICKS
|
||||
const i = ticks.indexOf(playbackTime)
|
||||
if (i < ticks.length - 1) set({ playbackTime: ticks[i + 1] })
|
||||
else set({ isPlaying: false })
|
||||
},
|
||||
|
||||
stepBack: () => {
|
||||
const { playbackTime } = get()
|
||||
const ticks = REPLAY_TICKS
|
||||
const i = ticks.indexOf(playbackTime)
|
||||
if (i > 0) set({ playbackTime: ticks[i - 1] })
|
||||
},
|
||||
|
||||
setSpeed: (sec) => set({ speedSecPerTick: sec }),
|
||||
}))
|
||||
Reference in New Issue
Block a user