This commit is contained in:
Daniel
2026-03-04 16:48:17 +08:00
parent 64f4c438c3
commit 26938449f0
34 changed files with 956 additions and 500 deletions

View File

@@ -55,6 +55,7 @@ export interface ForceSummaryRow {
}
export interface DisplayStatsRow {
overrideEnabled?: boolean
viewers: number
cumulative: number
shareCount: number
@@ -140,7 +141,10 @@ export async function putForceSummary(side: 'us' | 'iran', body: Partial<ForceSu
}
/** 传 null 的字段会清除覆盖,改回实时统计 */
export async function putDisplayStats(body: Partial<{ [K in keyof DisplayStatsRow]: number | null }>): Promise<void> {
/** 传 clearOverride: true 可关闭覆盖、恢复实时统计 */
export async function putDisplayStats(
body: Partial<{ [K in keyof DisplayStatsRow]: number | null }> & { clearOverride?: boolean }
): Promise<void> {
const res = await fetch('/api/edit/display-stats', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { StatCard } from './StatCard'
import { useSituationStore } from '@/store/situationStore'
import { useStatsStore } from '@/store/statsStore'
@@ -39,6 +39,8 @@ export function HeaderPanel() {
const [now, setNow] = useState(() => new Date())
const [likes, setLikes] = useState(getStoredLikes)
const [liked, setLiked] = useState(false)
const [likeBurst, setLikeBurst] = useState(0)
const pendingLikesRef = useRef(0)
const stats = useStatsStore((s) => s.stats)
const setStats = useStatsStore((s) => s.setStats)
const viewers = stats.viewers ?? 0
@@ -140,10 +142,7 @@ export function HeaderPanel() {
}
}
const handleLike = async () => {
if (liked || likeSending) return
setLiked(true)
setLikeSending(true)
const sendOneLike = async () => {
try {
const res = await fetch('/api/like', { method: 'POST' })
const data = await res.json()
@@ -159,21 +158,28 @@ export function HeaderPanel() {
try {
localStorage.setItem(STORAGE_LIKES, String(data.likeCount))
} catch {}
} else {
const next = likes + 1
setLikes(next)
try {
localStorage.setItem(STORAGE_LIKES, String(next))
} catch {}
}
} catch {
const next = likes + 1
setLikes(next)
try {
localStorage.setItem(STORAGE_LIKES, String(next))
} catch {}
setLikes((prev) => Math.max(0, prev - 1))
} finally {
setLikeSending(false)
if (pendingLikesRef.current > 0) {
pendingLikesRef.current -= 1
sendOneLike()
} else {
setLikeSending(false)
}
}
}
const handleLike = () => {
setLiked(true)
setLikes((prev) => (serverLikeCount ?? prev) + 1)
setLikeBurst((n) => n + 1)
if (!likeSending) {
setLikeSending(true)
sendOneLike()
} else {
pendingLikesRef.current += 1
}
}
@@ -213,9 +219,14 @@ export function HeaderPanel() {
{formatDateTime(now)}
</span>
</div>
{(isConnected || isReplayMode) && (
<span className={`text-[10px] ${isReplayMode ? 'text-military-accent' : 'text-green-500/90'}`}>
{formatDataTime(situation.lastUpdated)} {isReplayMode ? '(回放)' : '(实时更新)'}
{/* 非回放时显示数据更新时间,与后端 situation.updated_at 一致(爬虫 notify / 编辑保存时后端更新并广播) */}
{isReplayMode ? (
<span className="text-[10px] text-military-accent">
{formatDataTime(situation.lastUpdated)} ()
</span>
) : (
<span className="text-[10px] text-green-500/90">
{formatDataTime(situation.lastUpdated)} ()
</span>
)}
</div>
@@ -246,14 +257,25 @@ export function HeaderPanel() {
<button
type="button"
onClick={handleLike}
disabled={likeSending}
className={`flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-[9px] transition-colors sm:px-2 sm:py-1 sm:text-[10px] disabled:opacity-50 ${
className={`relative flex shrink-0 select-none items-center gap-1 rounded border px-1.5 py-0.5 text-[9px] transition-colors active:scale-95 sm:px-2 sm:py-1 sm:text-[10px] ${
liked
? 'border-red-500/50 bg-red-500/20 text-red-400'
: 'border-military-border text-military-text-secondary hover:bg-military-border/30 hover:text-red-400'
}`}
>
<Heart className={`h-2.5 w-2.5 sm:h-3 sm:w-3 ${liked ? 'fill-current' : ''}`} />
<span className="relative inline-flex">
<Heart className={`h-2.5 w-2.5 sm:h-3 sm:w-3 transition-transform duration-150 ${liked ? 'fill-current' : ''} ${likeBurst ? 'scale-125' : ''}`} />
{likeBurst > 0 && (
<span
key={likeBurst}
className="absolute -top-1 -right-1 min-w-[14px] rounded bg-red-500 px-0.5 text-[10px] font-bold text-white"
style={{ animation: 'likePop 0.5s ease-out forwards' }}
onAnimationEnd={() => setLikeBurst(0)}
>
+1
</span>
)}
</span>
{(serverLikeCount ?? likes) > 0 && <span className="tabular-nums">{serverLikeCount ?? likes}</span>}
</button>
<span className={`flex items-center gap-1 ${isConnected ? 'text-green-500' : 'text-military-text-secondary'}`}>

View File

@@ -56,7 +56,7 @@ export function RetaliationGauge({ value, history, className = '' }: Retaliation
fontSize: 16,
fontWeight: 'bold',
color: '#EF4444',
formatter: '{value}',
formatter: (val: number) => Number(val).toFixed(2),
},
data: [{ value }],
},

View File

@@ -1,6 +1,6 @@
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 { usePlaybackStore, getTicks, REPLAY_START, REPLAY_END, type ReplayScale } from '@/store/playbackStore'
import { useSituationStore } from '@/store/situationStore'
import { NewsTicker } from './NewsTicker'
import { config } from '@/config'
@@ -33,16 +33,20 @@ export function TimelinePanel() {
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(() => {
@@ -62,27 +66,30 @@ export function TimelinePanel() {
return
}
timerRef.current = setInterval(() => {
const current = usePlaybackStore.getState().playbackTime
const i = REPLAY_TICKS.indexOf(current)
if (i >= REPLAY_TICKS.length - 1) {
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(REPLAY_TICKS[i + 1])
setPlaybackTime(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 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(REPLAY_TICKS[i])
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 && (
@@ -142,7 +149,7 @@ export function TimelinePanel() {
<button
type="button"
onClick={stepForward}
disabled={index >= REPLAY_TICKS.length - 1}
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="下一步"
>
@@ -150,21 +157,45 @@ export function TimelinePanel() {
</button>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 lg:min-w-[320px]">
<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={REPLAY_TICKS.length - 1}
max={replayTicks.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"
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"
/>
</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>
{/* 按刻度划分的时间轴:均匀取约 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

View File

@@ -4,17 +4,8 @@ import type { MapRef } from 'react-map-gl'
import type { Map as MapboxMap } from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import { useReplaySituation } from '@/hooks/useReplaySituation'
import { usePlaybackStore } from '@/store/playbackStore'
import { config } from '@/config'
import {
ATTACKED_TARGETS,
ALLIED_STRIKE_LOCATIONS,
LINCOLN_COORDS,
LINCOLN_STRIKE_TARGETS,
FORD_COORDS,
FORD_STRIKE_TARGETS,
ISRAEL_STRIKE_SOURCE,
ISRAEL_STRIKE_TARGETS,
} from '@/data/mapLocations'
import { EXTENDED_WAR_ZONES } from '@/data/extendedWarData'
const MAPBOX_TOKEN = config.mapboxAccessToken || ''
@@ -65,8 +56,45 @@ const ALLIES_ADMIN = [
// 伊朗攻击源 德黑兰 [lng, lat]
const TEHRAN_SOURCE: [number, number] = [51.389, 35.6892]
// 真主党打击源(黎巴嫩南部大致位置),用于绘制向以色列北部的攻击矢量
const HEZBOLLAH_SOURCE: [number, number] = [35.3, 33.2]
// API 未返回 mapData 时的静态 fallback保证美/以打击线与动画不消失(与 server/seed.js 一致)
const FALLBACK_STRIKE_SOURCES: { id: string; name: string; lng: number; lat: number }[] = [
{ id: 'israel', name: '以色列', lng: 34.78, lat: 32.08 },
{ id: 'lincoln', name: '林肯号航母', lng: 58.4215, lat: 24.1568 },
{ id: 'ford', name: '福特号航母', lng: 24.1002, lat: 35.7397 },
]
const FALLBACK_STRIKE_LINES: { sourceId: string; targets: { lng: number; lat: number; name?: string }[] }[] = [
{
sourceId: 'israel',
targets: [
{ lng: 50.88, lat: 34.64, name: '库姆' },
{ lng: 50.876409, lat: 34.625448, name: '伊朗专家会议秘书处' },
{ lng: 51.916, lat: 33.666, name: '纳坦兹' },
{ lng: 51.002, lat: 35.808, name: '卡拉季无人机厂' },
],
},
{
sourceId: 'lincoln',
targets: [
{ lng: 56.27, lat: 27.18, name: '阿巴斯港海军司令部' },
{ lng: 57.08, lat: 27.13, name: '米纳布' },
{ lng: 56.5, lat: 27.0, name: '霍尔木兹岸防阵地' },
{ lng: 50.838, lat: 28.968, name: '布什尔雷达站' },
{ lng: 51.667, lat: 32.654, name: '伊斯法罕核设施' },
],
},
{
sourceId: 'ford',
targets: [
{ lng: 51.42, lat: 35.69, name: '哈梅内伊官邸' },
{ lng: 51.41, lat: 35.72, name: '总统府/情报部' },
{ lng: 51.15, lat: 35.69, name: '梅赫拉巴德机场' },
{ lng: 46.29, lat: 38.08, name: '大不里士空军基地' },
{ lng: 47.076, lat: 34.314, name: '克尔曼沙赫导弹掩体' },
{ lng: 46.42, lat: 33.64, name: '伊拉姆导弹阵地' },
{ lng: 48.35, lat: 33.48, name: '霍拉马巴德储备库' },
],
},
]
/** 二次贝塞尔曲线路径,更平滑的弧线 height 控制弧高 */
function parabolaPath(
@@ -157,6 +185,7 @@ export function WarMap() {
const hezbollahPathsRef = useRef<[number, number][][]>([])
const hormuzPathsRef = useRef<[number, number][][]>([])
const situation = useReplaySituation()
const { isReplayMode } = usePlaybackStore()
const { usForces, iranForces, conflictEvents = [] } = situation
const usLocs = (usForces.keyLocations || []) as KeyLoc[]
@@ -196,30 +225,52 @@ export function WarMap() {
}
}, [usForces.keyLocations, iranForces.keyLocations])
// 德黑兰到 27 个被袭目标的攻击路径(静态线条)
const mapData = situation.mapData
const attackedTargets = mapData?.attackedTargets ?? []
const strikeSources =
mapData?.strikeSources?.length > 0 ? mapData.strikeSources : FALLBACK_STRIKE_SOURCES
const strikeLines =
mapData?.strikeLines?.length > 0 ? mapData.strikeLines : FALLBACK_STRIKE_LINES
const attackPaths = useMemo(
() => ATTACKED_TARGETS.map((target) => parabolaPath(TEHRAN_SOURCE, target as [number, number])),
[]
() => attackedTargets.map((target) => parabolaPath(TEHRAN_SOURCE, target as [number, number])),
[attackedTargets]
)
attackPathsRef.current = attackPaths
const lincolnPaths = useMemo(
() => LINCOLN_STRIKE_TARGETS.map((t) => parabolaPath(LINCOLN_COORDS, t)),
[]
)
const fordPaths = useMemo(
() => FORD_STRIKE_TARGETS.map((t) => parabolaPath(FORD_COORDS, t)),
[]
)
const israelPaths = useMemo(
() => ISRAEL_STRIKE_TARGETS.map((t) => parabolaPath(ISRAEL_STRIKE_SOURCE, t)),
[]
)
// 真主党 → 以色列北部三处目标(低平弧线)
const sourceCoords = useMemo(() => {
const m: Record<string, [number, number]> = {}
strikeSources.forEach((s) => { m[s.id] = [s.lng, s.lat] })
return m
}, [strikeSources])
const lincolnPaths = useMemo(() => {
const line = strikeLines.find((l) => l.sourceId === 'lincoln')
const coords = sourceCoords.lincoln
if (!coords || !line) return []
return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat]))
}, [strikeLines, sourceCoords])
const fordPaths = useMemo(() => {
const line = strikeLines.find((l) => l.sourceId === 'ford')
const coords = sourceCoords.ford
if (!coords || !line) return []
return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat]))
}, [strikeLines, sourceCoords])
const israelPaths = useMemo(() => {
const line = strikeLines.find((l) => l.sourceId === 'israel')
const coords = sourceCoords.israel
if (!coords || !line) return []
return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat]))
}, [strikeLines, sourceCoords])
// 真主党 → 以色列北部三处目标(与美/以打击弧线一致:同一 parabola 高度与动画方式)
const hezbollahSource = EXTENDED_WAR_ZONES.hezbollahStrikeSource
const hezbollahPaths = useMemo(
() => EXTENDED_WAR_ZONES.activeAttacks.map((t) => parabolaPath(HEZBOLLAH_SOURCE, t.coords, 1.5)),
[]
() =>
isReplayMode
? []
: EXTENDED_WAR_ZONES.activeAttacks.map((t) => parabolaPath(hezbollahSource, t.coords, 3)),
[hezbollahSource, isReplayMode]
)
// 伊朗不同地点 → 霍尔木兹海峡多点攻击(黄色轨迹)
const hormuzTargetPoints = useMemo(
@@ -232,6 +283,7 @@ export function WarMap() {
[]
)
const hormuzPaths = useMemo(() => {
if (isReplayMode) return []
// 使用更远的伊朗腹地/纵深位置,弧线更明显
const sources: [number, number][] = [
TEHRAN_SOURCE, // 德黑兰
@@ -241,7 +293,7 @@ export function WarMap() {
return hormuzTargetPoints.map((target, idx) =>
parabolaPath(sources[idx % sources.length], target, 3)
)
}, [hormuzTargetPoints])
}, [hormuzTargetPoints, isReplayMode])
lincolnPathsRef.current = lincolnPaths
fordPathsRef.current = fordPaths
israelPathsRef.current = israelPaths
@@ -319,28 +371,34 @@ export function WarMap() {
// 真主党当前攻击目标点
const hezbollahTargetsGeoJson = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: EXTENDED_WAR_ZONES.activeAttacks.map((t) => ({
type: 'Feature' as const,
properties: { name: t.name, type: t.type, damage: t.damage },
geometry: { type: 'Point' as const, coordinates: t.coords },
})),
}),
[]
() =>
isReplayMode
? { type: 'FeatureCollection' as const, features: [] }
: {
type: 'FeatureCollection' as const,
features: EXTENDED_WAR_ZONES.activeAttacks.map((t) => ({
type: 'Feature' as const,
properties: { name: t.name, type: t.type, damage: t.damage },
geometry: { type: 'Point' as const, coordinates: t.coords },
})),
},
[isReplayMode]
)
// 霍尔木兹海峡被持续打击的海面目标(用于脉冲与标记)
const hormuzTargetsGeoJson = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: hormuzTargetPoints.map((coords, idx) => ({
type: 'Feature' as const,
properties: { id: `H${idx + 1}` },
geometry: { type: 'Point' as const, coordinates: coords },
})),
}),
[hormuzTargetPoints]
() =>
isReplayMode
? { type: 'FeatureCollection' as const, features: [] }
: {
type: 'FeatureCollection' as const,
features: hormuzTargetPoints.map((coords, idx) => ({
type: 'Feature' as const,
properties: { id: `H${idx + 1}` },
geometry: { type: 'Point' as const, coordinates: coords },
})),
},
[hormuzTargetPoints, isReplayMode]
)
// 霍尔木兹海峡交战区 & 真主党势力范围(静态面)
@@ -491,7 +549,7 @@ export function WarMap() {
)
israelSrc.setData({ type: 'FeatureCollection', features })
}
// 真主党打击以色列北部:橙红光点,低平飞行
// 真主党打击以色列北部:橙红光点,与林肯/福特/以色列同一动画方式
const hezSrc = map.getSource('hezbollah-strike-dots') as
| { setData: (d: GeoJSON.FeatureCollection) => void }
| undefined
@@ -550,12 +608,12 @@ export function WarMap() {
map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r)
map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)
}
// 真主党攻击目标:橙红脉冲,效果与 allied-strike-targets 保持一致
// 真主党攻击目标:橙红脉冲,与 allied-strike-targets 同一周期与半径
if (map.getLayer('hezbollah-attack-targets-pulse')) {
const cycle = 2000
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
const r = Math.max(0, 30 * phase * zoomScale)
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.15))
const r = Math.max(0, 35 * phase * zoomScale)
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.2))
map.setPaintProperty('hezbollah-attack-targets-pulse', 'circle-radius', r)
map.setPaintProperty('hezbollah-attack-targets-pulse', 'circle-opacity', opacity)
}
@@ -891,18 +949,18 @@ export function WarMap() {
/>
</Source>
{/* 真主党对以色列北部的攻击矢量线(低平红线 */}
{/* 真主党对以色列北部的攻击矢量线(与林肯/福特/以色列线宽一致 */}
<Source id="hezbollah-attack-lines" type="geojson" data={hezbollahLinesGeoJson}>
<Layer
id="hezbollah-attack-lines"
type="line"
paint={{
'line-color': 'rgba(248, 113, 113, 0.7)',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.6, 8, 1.2, 12, 2],
'line-color': 'rgba(248, 113, 113, 0.45)',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.5, 8, 1, 12, 2],
}}
/>
</Source>
{/* 真主党打击光点(沿矢量路径移动 */}
{/* 真主党打击光点(与林肯/福特/以色列光点半径与动画一致 */}
<Source
id="hezbollah-strike-dots"
type="geojson"
@@ -919,17 +977,17 @@ export function WarMap() {
id="hezbollah-strike-dots-glow"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 2.5, 8, 4.5, 12, 7],
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 6, 12, 10],
'circle-color': 'rgba(248, 113, 113, 0.6)',
'circle-blur': 0.25,
'circle-blur': 0.3,
}}
/>
<Layer
id="hezbollah-strike-dots-core"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 2, 12, 3.5],
'circle-color': '#fb923c',
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 2, 12, 4],
'circle-color': '#F97316',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
@@ -1093,10 +1151,10 @@ export function WarMap() {
type="geojson"
data={{
type: 'FeatureCollection',
features: ALLIED_STRIKE_LOCATIONS.map((s) => ({
features: (situation.iranForces?.keyLocations ?? []).map((s) => ({
type: 'Feature' as const,
properties: { name: s.name },
geometry: { type: 'Point' as const, coordinates: s.coords },
geometry: { type: 'Point' as const, coordinates: [s.lng, s.lat] },
})),
}}
>

View File

@@ -2,11 +2,12 @@
// 仅用于前端展示,不参与任何真实评估
export const EXTENDED_WAR_ZONES = {
// 1. 霍尔木兹海峡交战区 (Strait of Hormuz) — 多边形,包络海峡水道及两侧水域 [lng, lat]
// 1. 霍尔木兹海峡交战区 — 伊朗国境线沿岸(波斯湾→海峡→阿曼湾)+ 阿曼穆桑代姆 + 波斯湾出口 [lng, lat]
hormuzCombatZone: {
type: 'Feature' as const,
properties: {
name: '霍尔木兹海峡交战区',
name_en: 'Strait of Hormuz Area',
status: 'BLOCKED / ENGAGED',
style: {
fillColor: '#FFD700',
@@ -18,24 +19,33 @@ export const EXTENDED_WAR_ZONES = {
type: 'Polygon' as const,
coordinates: [
[
[55.0, 25.0],
[55.5, 25.4],
[56.2, 26.0],
[56.8, 26.6],
[57.2, 27.0],
[57.0, 27.4],
[56.4, 27.2],
[55.8, 26.6],
[55.2, 25.9],
[54.8, 25.4],
[55.0, 25.0],
[55.92, 27.02], // 波斯湾入口(伊朗西侧,近阿联酋水道)
[56.12, 27.08], // 伊朗沿岸向东
[56.27, 27.18], // 阿巴斯港一带(伊朗国境线)
[56.35, 27.05], // 格什姆岛西北侧伊朗主陆
[56.28, 26.92], // 格什姆岛北缘(伊朗海岸)
[56.45, 26.88], // 格什姆东侧水道(伊朗岸)
[56.62, 26.78], // 伊朗沿岸向东
[56.88, 26.58], // 米纳布方向(伊朗海岸)
[57.08, 26.42], // 锡里克Sirik附近伊朗国境线
[57.38, 25.88], // 库角Ras al Kuh
[57.52, 25.72], // 库角Ras al Kuh
[57.77, 25.64], // 贾斯克Jask— 阿曼湾开口伊朗侧
[56.26, 25.61], // 迪巴Dibba— 阿曼湾开口阿曼侧
[56.34, 25.92], // 穆桑代姆东海岸
[56.38, 26.18], // 穆桑代姆东海岸
[56.4, 26.35], // 拉斯·穆桑代姆最北端
[56.24, 26.22], // 穆桑代姆西侧狭窄水道
[56.08, 26.0], // 穆桑代姆西海岸
[55.96, 26.05], // 波斯湾出口(阿曼/阿联酋侧)
[55.92, 27.02], // 闭合:回到起点
],
],
},
},
// 霍尔木兹区域标注点(多边形中心附近,用于显示文字)
hormuzLabelCenter: [56.0, 26.2] as [number, number],
hormuzLabelCenter: [56.5, 26.35] as [number, number],
// 2. 真主党势力范围 (Hezbollah) — 黎巴嫩南部 + 贝卡谷地,多边形 [lng, lat]
hezbollahZone: {
@@ -81,6 +91,9 @@ export const EXTENDED_WAR_ZONES = {
// 真主党区域标注点(用于显示文字)
hezbollahLabelCenter: [35.7, 33.7] as [number, number],
// 真主党打击源(黎巴嫩南部,与势力范围一致,用于攻击矢量起点)[lng, lat]
hezbollahStrikeSource: [35.32, 33.28] as [number, number],
// 3. 真主党当前攻击目标 (North Israel Targets)
activeAttacks: [
{

View File

@@ -1,188 +1,15 @@
/** 航母标记 - 全部中文 */
export const CARRIER_MARKERS = [
{
id: 'CVN-72',
name: '林肯号航母',
coordinates: [58.4215, 24.1568] as [number, number],
type: 'Aircraft Carrier',
status: 'Active - Combat Readiness',
details: '林肯号航母打击群 (CSG-3) 部署于北阿拉伯海。',
},
{
id: 'CVN-78',
name: '福特号航母',
coordinates: [24.1002, 35.7397] as [number, number],
type: 'Aircraft Carrier',
status: 'Active - Forward Deployed',
details: '距克里特苏达湾约 15 公里。',
},
]
/**
* 地图点位类型定义。实际数据来自 APIgetSituation 的 usForces.keyLocations / iranForces.keyLocations 与 mapData
*/
export type KeyLocItem = {
name: string
lat: number
lng: number
type?: string
region?: string
id?: number
status?: 'operational' | 'damaged' | 'attacked'
damage_level?: number
/** 遭袭时间 ISO 字符串,用于状态管理与数据回放 */
attacked_at?: string | null
}
/** 美军基地总数 62被袭击 27 个。损毁程度:严重 6 / 中度 12 / 轻度 9 */
const ATTACKED_BASES = [
// 严重损毁 (6): 高价值目标,近伊朗
{ name: '阿萨德空军基地', lat: 33.785, lng: 42.441, region: '伊拉克', damage_level: 3 },
{ name: '巴格达外交支援中心', lat: 33.315, lng: 44.366, region: '伊拉克', damage_level: 3 },
{ name: '乌代德空军基地', lat: 25.117, lng: 51.314, region: '卡塔尔', damage_level: 3 },
{ name: '埃尔比勒空军基地', lat: 36.237, lng: 43.963, region: '伊拉克', damage_level: 3 },
{ name: '因吉尔利克空军基地', lat: 37.002, lng: 35.425, region: '土耳其', damage_level: 3 },
{ name: '苏尔坦亲王空军基地', lat: 24.062, lng: 47.58, region: '沙特', damage_level: 3 },
// 中度损毁 (12)
{ name: '塔吉军营', lat: 33.556, lng: 44.256, region: '伊拉克', damage_level: 2 },
{ name: '阿因·阿萨德', lat: 33.8, lng: 42.45, region: '伊拉克', damage_level: 2 },
{ name: '坦夫驻军', lat: 33.49, lng: 38.618, region: '叙利亚', damage_level: 2 },
{ name: '沙达迪基地', lat: 36.058, lng: 40.73, region: '叙利亚', damage_level: 2 },
{ name: '康诺克气田基地', lat: 35.336, lng: 40.295, region: '叙利亚', damage_level: 2 },
{ name: '尔梅兰着陆区', lat: 37.015, lng: 41.885, region: '叙利亚', damage_level: 2 },
{ name: '阿里夫坚军营', lat: 28.832, lng: 47.799, region: '科威特', damage_level: 2 },
{ name: '阿里·萨勒姆空军基地', lat: 29.346, lng: 47.52, region: '科威特', damage_level: 2 },
{ name: '巴林海军支援站', lat: 26.236, lng: 50.608, region: '巴林', damage_level: 2 },
{ name: '达夫拉空军基地', lat: 24.248, lng: 54.547, region: '阿联酋', damage_level: 2 },
{ name: '埃斯康村', lat: 24.774, lng: 46.738, region: '沙特', damage_level: 2 },
{ name: '内瓦提姆空军基地', lat: 31.208, lng: 35.012, region: '以色列', damage_level: 2 },
// 轻度损毁 (9)
{ name: '布林军营', lat: 29.603, lng: 47.456, region: '科威特', damage_level: 1 },
{ name: '赛利耶军营', lat: 25.275, lng: 51.52, region: '卡塔尔', damage_level: 1 },
{ name: '拉蒙空军基地', lat: 30.776, lng: 34.666, region: '以色列', damage_level: 1 },
{ name: '穆瓦法克·萨尔蒂空军基地', lat: 32.356, lng: 36.259, region: '约旦', damage_level: 1 },
{ name: '屈雷吉克雷达站', lat: 38.354, lng: 37.794, region: '土耳其', damage_level: 1 },
{ name: '苏姆莱特空军基地', lat: 17.666, lng: 54.024, region: '阿曼', damage_level: 1 },
{ name: '马西拉空军基地', lat: 20.675, lng: 58.89, region: '阿曼', damage_level: 1 },
{ name: '西开罗空军基地', lat: 30.915, lng: 30.298, region: '埃及', damage_level: 1 },
{ name: '勒莫尼耶军营', lat: 11.547, lng: 43.159, region: '吉布提', damage_level: 1 },
]
/** 35 个新增 operational 基地 */
const NEW_BASES: KeyLocItem[] = [
{ name: '多哈后勤中心', lat: 25.29, lng: 51.53, type: 'Base', region: '卡塔尔' },
{ name: '贾法勒海军站', lat: 26.22, lng: 50.62, type: 'Base', region: '巴林' },
{ name: '阿兹祖尔前方作战点', lat: 29.45, lng: 47.9, type: 'Base', region: '科威特' },
{ name: '艾哈迈迪后勤枢纽', lat: 29.08, lng: 48.09, type: 'Base', region: '科威特' },
{ name: '富查伊拉港站', lat: 25.13, lng: 56.35, type: 'Base', region: '阿联酋' },
{ name: '哈伊马角前方点', lat: 25.79, lng: 55.94, type: 'Base', region: '阿联酋' },
{ name: '利雅得联络站', lat: 24.71, lng: 46.68, type: 'Base', region: '沙特' },
{ name: '朱拜勒港支援点', lat: 27.0, lng: 49.65, type: 'Base', region: '沙特' },
{ name: '塔布克空军前哨', lat: 28.38, lng: 36.6, type: 'Base', region: '沙特' },
{ name: '拜莱德空军基地', lat: 33.94, lng: 44.36, type: 'Base', region: '伊拉克' },
{ name: '巴士拉后勤站', lat: 30.5, lng: 47.78, type: 'Base', region: '伊拉克' },
{ name: '基尔库克前哨', lat: 35.47, lng: 44.35, type: 'Base', region: '伊拉克' },
{ name: '摩苏尔支援点', lat: 36.34, lng: 43.14, type: 'Base', region: '伊拉克' },
{ name: '哈塞克联络站', lat: 36.5, lng: 40.75, type: 'Base', region: '叙利亚' },
{ name: '代尔祖尔前哨', lat: 35.33, lng: 40.14, type: 'Base', region: '叙利亚' },
{ name: '安曼协调中心', lat: 31.95, lng: 35.93, type: 'Base', region: '约旦' },
{ name: '伊兹密尔支援站', lat: 38.42, lng: 27.14, type: 'Base', region: '土耳其' },
{ name: '哈泽瑞姆空军基地', lat: 31.07, lng: 34.84, type: 'Base', region: '以色列' },
{ name: '杜古姆港站', lat: 19.66, lng: 57.76, type: 'Base', region: '阿曼' },
{ name: '塞拉莱前方点', lat: 17.01, lng: 54.1, type: 'Base', region: '阿曼' },
{ name: '亚历山大港联络站', lat: 31.2, lng: 29.9, type: 'Base', region: '埃及' },
{ name: '卢克索前哨', lat: 25.69, lng: 32.64, type: 'Base', region: '埃及' },
{ name: '吉布提港支援点', lat: 11.59, lng: 43.15, type: 'Base', region: '吉布提' },
{ name: '卡塔尔应急医疗站', lat: 25.22, lng: 51.45, type: 'Base', region: '卡塔尔' },
{ name: '沙特哈立德国王基地', lat: 24.96, lng: 46.7, type: 'Base', region: '沙特' },
{ name: '伊拉克巴拉德联勤站', lat: 33.75, lng: 44.25, type: 'Base', region: '伊拉克' },
{ name: '叙利亚奥马尔油田站', lat: 36.22, lng: 40.45, type: 'Base', region: '叙利亚' },
{ name: '约旦侯赛因国王基地', lat: 31.72, lng: 36.01, type: 'Base', region: '约旦' },
{ name: '土耳其巴特曼站', lat: 37.88, lng: 41.13, type: 'Base', region: '土耳其' },
{ name: '以色列帕尔马欣站', lat: 31.9, lng: 34.95, type: 'Base', region: '以色列' },
{ name: '阿曼杜古姆扩建点', lat: 19.55, lng: 57.8, type: 'Base', region: '阿曼' },
{ name: '埃及纳特龙湖站', lat: 30.37, lng: 30.2, type: 'Base', region: '埃及' },
{ name: '吉布提查贝尔达站', lat: 11.73, lng: 42.9, type: 'Base', region: '吉布提' },
{ name: '阿联酋迪拜港联络', lat: 25.27, lng: 55.3, type: 'Base', region: '阿联酋' },
{ name: '伊拉克尼尼微前哨', lat: 36.22, lng: 43.1, type: 'Base', region: '伊拉克' },
]
/** 美军全部地图点位2 航母 + 9 海军 + 62 基地 */
export const US_KEY_LOCATIONS: KeyLocItem[] = [
...CARRIER_MARKERS.map((c) => ({
name: c.name + ` (${c.id})`,
lat: c.coordinates[1],
lng: c.coordinates[0],
type: 'Aircraft Carrier' as const,
region: c.id === 'CVN-72' ? '北阿拉伯海' : '东地中海',
status: 'operational' as const,
damage_level: undefined as number | undefined,
})),
{ name: '驱逐舰(阿曼湾)', lat: 25.2, lng: 58.0, type: 'Destroyer', region: '阿曼湾', status: 'operational' },
{ name: '海岸警卫队 1', lat: 25.4, lng: 58.2, type: 'Coast Guard', region: '阿曼湾', status: 'operational' },
{ name: '海岸警卫队 2', lat: 25.0, lng: 57.8, type: 'Coast Guard', region: '阿曼湾', status: 'operational' },
{ name: '驱逐舰(波斯湾北部)', lat: 26.5, lng: 51.0, type: 'Destroyer', region: '波斯湾', status: 'operational' },
{ name: '护卫舰 1', lat: 26.7, lng: 50.6, type: 'Frigate', region: '波斯湾', status: 'operational' },
{ name: '护卫舰 2', lat: 27.0, lng: 50.2, type: 'Frigate', region: '波斯湾', status: 'operational' },
{ name: '护卫舰 3', lat: 26.3, lng: 50.8, type: 'Frigate', region: '波斯湾', status: 'operational' },
{ name: '辅助舰 1', lat: 26.0, lng: 51.2, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
{ name: '辅助舰 2', lat: 25.8, lng: 51.5, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
{ name: '辅助舰 3', lat: 26.2, lng: 50.9, type: 'Auxiliary', region: '波斯湾', status: 'operational' },
...ATTACKED_BASES.map((b) => ({
...b,
type: 'Base' as const,
status: 'attacked' as const,
})),
...NEW_BASES,
]
/** 被袭击的 27 个基地坐标 [lng, lat],用于绘制攻击曲线 */
export const ATTACKED_TARGETS: [number, number][] = ATTACKED_BASES.map((b) => [b.lng, b.lat])
/** 美以联军打击伊朗目标 (2026.03.01) - 中文标注coords [lng, lat] */
export const ALLIED_STRIKE_LOCATIONS = [
// 1. 核心指挥与政治中枢
{ name: '哈梅内伊官邸', coords: [51.42, 35.69] as [number, number], type: 'Leadership' },
{ name: '总统府/情报部', coords: [51.41, 35.72] as [number, number], type: 'Leadership' },
{ name: '梅赫拉巴德机场', coords: [51.15, 35.69] as [number, number], type: 'Leadership' },
{ name: '库姆', coords: [50.88, 34.64] as [number, number], type: 'Leadership' },
// 2. 核设施与战略研究点
{ name: '伊斯法罕核设施', coords: [51.667, 32.654] as [number, number], type: 'Nuclear' },
{ name: '纳坦兹', coords: [51.916, 33.666] as [number, number], type: 'Nuclear' },
{ name: '布什尔雷达站', coords: [50.838, 28.968] as [number, number], type: 'Nuclear' },
// 3. 导弹与无人机基地
{ name: '卡拉季无人机厂', coords: [51.002, 35.808] as [number, number], type: 'UAV' },
{ name: '克尔曼沙赫导弹掩体', coords: [47.076, 34.314] as [number, number], type: 'Missile' },
{ name: '大不里士空军基地', coords: [46.29, 38.08] as [number, number], type: 'Missile' },
{ name: '伊拉姆导弹阵地', coords: [46.42, 33.64] as [number, number], type: 'Missile' },
{ name: '霍拉马巴德储备库', coords: [48.35, 33.48] as [number, number], type: 'Missile' },
// 4. 海军与南部封锁节点
{ name: '阿巴斯港海军司令部', coords: [56.27, 27.18] as [number, number], type: 'Naval' },
{ name: '米纳布', coords: [57.08, 27.13] as [number, number], type: 'Naval' },
{ name: '霍尔木兹岸防阵地', coords: [56.5, 27.0] as [number, number], type: 'Naval' },
]
/** 盟军打击目标坐标 [lng, lat] */
export const ALLIED_STRIKE_TARGETS: [number, number][] = ALLIED_STRIKE_LOCATIONS.map((s) => s.coords)
/** 林肯号航母位置 [lng, lat] - 北阿拉伯海 */
export const LINCOLN_COORDS: [number, number] = [58.4215, 24.1568]
/** 福特号航母位置 [lng, lat] - 东地中海 */
export const FORD_COORDS: [number, number] = [24.1002, 35.7397]
/** 以色列打击源 [lng, lat] - 特拉维夫附近 */
export const ISRAEL_STRIKE_SOURCE: [number, number] = [34.78, 32.08]
/** 林肯号打击目标:南部海军/核设施 */
export const LINCOLN_STRIKE_TARGETS: [number, number][] = [
[56.27, 27.18], [57.08, 27.13], [56.5, 27.0], // 阿巴斯港、米纳布、霍尔木兹
[50.838, 28.968], [51.667, 32.654], // 布什尔、伊斯法罕
]
/** 福特号打击目标:北部/西部 */
export const FORD_STRIKE_TARGETS: [number, number][] = [
[51.42, 35.69], [51.41, 35.72], [51.15, 35.69], // 德黑兰核心
[46.29, 38.08], [47.076, 34.314], [46.42, 33.64], [48.35, 33.48], // 大不里士、克尔曼沙赫、伊拉姆、霍拉马巴德
]
/** 以色列打击目标:中部核设施/指挥 */
export const ISRAEL_STRIKE_TARGETS: [number, number][] = [
[50.88, 34.64], [51.916, 33.666], [51.002, 35.808], // 库姆、纳坦兹、卡拉季
]
export const IRAN_KEY_LOCATIONS: KeyLocItem[] = [
{ name: '阿巴斯港', lat: 27.1832, lng: 56.2666, type: 'Port', region: '伊朗' },
{ name: '德黑兰', lat: 35.6892, lng: 51.389, type: 'Capital', region: '伊朗' },
{ name: '布什尔', lat: 28.9681, lng: 50.838, type: 'Base', region: '伊朗' },
]

View File

@@ -1,5 +1,4 @@
// TypeScript interfaces for military situation data
import { US_KEY_LOCATIONS, IRAN_KEY_LOCATIONS } from './mapLocations'
export interface ForceAsset {
id: string
@@ -90,6 +89,8 @@ export interface MilitarySituation {
id?: number
status?: 'operational' | 'damaged' | 'attacked'
damage_level?: number
/** 遭袭时间 ISO 字符串,用于状态管理与回放 */
attacked_at?: string | null
}[]
combatLosses: CombatLosses
/** 华尔街财团投入趋势 { time: ISO string, value: 0-100 } */
@@ -108,6 +109,7 @@ export interface MilitarySituation {
id?: number
status?: 'operational' | 'damaged' | 'attacked'
damage_level?: number
attacked_at?: string | null
}[]
combatLosses: CombatLosses
/** 反击情绪指标 0-100 */
@@ -122,6 +124,15 @@ export interface MilitarySituation {
conflictStats?: ConflictStats
/** 平民伤亡合计(不区分阵营) */
civilianCasualtiesTotal?: { killed: number; wounded: number }
/** 地图打击数据(来自 DB被袭美军基地、打击源、源→目标连线含攻击时间便于回放 */
mapData?: {
attackedTargets: [number, number][]
strikeSources: { id: string; name: string; lng: number; lat: number }[]
strikeLines: {
sourceId: string
targets: { lng: number; lat: number; name?: string; struck_at?: string | null }[]
}[]
}
}
export const INITIAL_MOCK_DATA: MilitarySituation = {
@@ -154,7 +165,7 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
{ id: 'us-8', name: 'MQ-9 死神', type: '无人机', count: 28, status: 'active' },
{ id: 'us-9', name: 'MQ-1C 灰鹰', type: '无人机', count: 45, status: 'active' },
],
keyLocations: US_KEY_LOCATIONS,
keyLocations: [],
combatLosses: {
bases: { destroyed: 0, damaged: 2 },
personnelCasualties: { killed: 127, wounded: 384 },
@@ -211,7 +222,7 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
{ id: 'ir-8', name: '法塔赫 (Fattah)', type: '导弹', count: 12, status: 'alert' },
{ id: 'ir-9', name: '穆哈杰-6', type: '无人机', count: 280, status: 'active' },
],
keyLocations: IRAN_KEY_LOCATIONS,
keyLocations: [],
combatLosses: {
bases: { destroyed: 3, damaged: 8 },
personnelCasualties: { killed: 2847, wounded: 5620 },
@@ -274,4 +285,74 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
conflictEvents: [],
conflictStats: { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
civilianCasualtiesTotal: { killed: 430, wounded: 1255 },
// 与 server/seed.js 一致,首屏与未连 API 时也有打击数据,前端可正常绘制攻击线与动画
mapData: {
attackedTargets: [
[42.441, 33.785],
[44.366, 33.315],
[51.314, 25.117],
[43.963, 36.237],
[35.425, 37.002],
[47.58, 24.062],
[44.256, 33.556],
[42.45, 33.8],
[38.618, 33.49],
[40.73, 36.058],
[40.295, 35.336],
[41.885, 37.015],
[47.799, 28.832],
[47.52, 29.346],
[50.608, 26.236],
[54.547, 24.248],
[46.738, 24.774],
[35.012, 31.208],
[47.456, 29.603],
[51.52, 25.275],
[34.666, 30.776],
[36.259, 32.356],
[37.794, 38.354],
[54.024, 17.666],
[58.89, 20.675],
[30.298, 30.915],
[43.159, 11.547],
] as [number, number][],
strikeSources: [
{ id: 'israel', name: '以色列', lng: 34.78, lat: 32.08 },
{ id: 'lincoln', name: '林肯号航母', lng: 58.4215, lat: 24.1568 },
{ id: 'ford', name: '福特号航母', lng: 24.1002, lat: 35.7397 },
],
strikeLines: [
{
sourceId: 'israel',
targets: [
{ lng: 50.88, lat: 34.64, name: '库姆' },
{ lng: 50.876409, lat: 34.625448, name: '伊朗专家会议秘书处' },
{ lng: 51.916, lat: 33.666, name: '纳坦兹' },
{ lng: 51.002, lat: 35.808, name: '卡拉季无人机厂' },
],
},
{
sourceId: 'lincoln',
targets: [
{ lng: 56.27, lat: 27.18, name: '阿巴斯港海军司令部' },
{ lng: 57.08, lat: 27.13, name: '米纳布' },
{ lng: 56.5, lat: 27.0, name: '霍尔木兹岸防阵地' },
{ lng: 50.838, lat: 28.968, name: '布什尔雷达站' },
{ lng: 51.667, lat: 32.654, name: '伊斯法罕核设施' },
],
},
{
sourceId: 'ford',
targets: [
{ lng: 51.42, lat: 35.69, name: '哈梅内伊官邸' },
{ lng: 51.41, lat: 35.72, name: '总统府/情报部' },
{ lng: 51.15, lat: 35.69, name: '梅赫拉巴德机场' },
{ lng: 46.29, lat: 38.08, name: '大不里士空军基地' },
{ lng: 47.076, lat: 34.314, name: '克尔曼沙赫导弹掩体' },
{ lng: 46.42, lat: 33.64, name: '伊拉姆导弹阵地' },
{ lng: 48.35, lat: 33.48, name: '霍拉马巴德储备库' },
],
},
],
},
}

View File

@@ -1,15 +1,26 @@
import { useMemo } from 'react'
import type { MilitarySituation } from '@/data/mockData'
import { useSituationStore } from '@/store/situationStore'
import { usePlaybackStore } from '@/store/playbackStore'
import { usePlaybackStore, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
/** 将系列时间映射到回放日 (2026-03-01) 以便按当天时刻插值 */
/** 盟军打击伊朗结束时刻:伊朗战损在此阶段增长 */
const ALLIED_STRIKE_END = '2026-02-28T04:00:00.000Z'
/** 伊朗反击开始时刻:美军战损在此阶段增长 */
const IRAN_RETALIATION_START = '2026-02-28T06:00:00.000Z'
/** 将系列时间映射到回放日以便按当天时刻插值 */
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 atOrBefore(ts: string | null | undefined, playbackTime: string): boolean {
if (!ts) return true
return new Date(ts).getTime() <= new Date(playbackTime).getTime()
}
function interpolateAt(
series: { time: string; value: number }[],
at: string,
@@ -43,7 +54,7 @@ function linearProgress(start: string, end: string, at: string): number {
return (ta - ts) / (te - ts)
}
/** 根据回放时刻派生态势数据 */
/** 根据回放时刻派生态势数据:按日期刻度过滤,只显示 attacked_at / struck_at / timestamp 不晚于 playbackTime 的数据库数据 */
export function useReplaySituation(): MilitarySituation {
const situation = useSituationStore((s) => s.situation)
const { isReplayMode, playbackTime } = usePlaybackStore()
@@ -51,86 +62,110 @@ export function useReplaySituation(): MilitarySituation {
return useMemo(() => {
if (!isReplayMode) return situation
const progress = linearProgress('2026-03-01T02:00:00.000Z', '2026-03-01T11:45:00.000Z', playbackTime)
// 战损阶段:伊朗(盟军打击 02:0004:00先行美军伊朗反击 06:00 起)随后
const progressIran = linearProgress(REPLAY_START, ALLIED_STRIKE_END, playbackTime)
const progressUs = linearProgress(IRAN_RETALIATION_START, REPLAY_END, playbackTime)
const lerpIran = (a: number, b: number) => Math.round(a + progressIran * (b - a))
const lerpUs = (a: number, b: number) => Math.round(a + progressUs * (b - a))
// 华尔街趋势、反击情绪:按时间插值
const wsValue = interpolateAt(situation.usForces.wallStreetInvestmentTrend, playbackTime)
const retValue = interpolateAt(situation.iranForces.retaliationSentimentHistory, playbackTime)
// 华尔街趋势、反击情绪:按回放日当天的时刻插值,保留两位小数避免穿模
const replayDay = playbackTime.slice(0, 10)
const wsValue = Math.round(interpolateAt(situation.usForces.wallStreetInvestmentTrend, playbackTime, replayDay) * 100) / 100
const retValue = Math.round(interpolateAt(situation.iranForces.retaliationSentimentHistory, playbackTime, replayDay) * 100) / 100
// 战斗损失:从 0 线性增长到当前值
const lerp = (a: number, b: number) => Math.round(a + progress * (b - a))
const usLoss = situation.usForces.combatLosses
const irLoss = situation.iranForces.combatLosses
const civTotal = situation.civilianCasualtiesTotal ?? { killed: 0, wounded: 0 }
const usLossesAt = {
bases: {
destroyed: lerp(0, usLoss.bases.destroyed),
damaged: lerp(0, usLoss.bases.damaged),
destroyed: lerpUs(0, usLoss.bases.destroyed),
damaged: lerpUs(0, usLoss.bases.damaged),
},
personnelCasualties: {
killed: lerp(0, usLoss.personnelCasualties.killed),
wounded: lerp(0, usLoss.personnelCasualties.wounded),
killed: lerpUs(0, usLoss.personnelCasualties.killed),
wounded: lerpUs(0, usLoss.personnelCasualties.wounded),
},
civilianCasualties: { killed: 0, wounded: 0 },
aircraft: lerp(0, usLoss.aircraft),
warships: lerp(0, usLoss.warships),
armor: lerp(0, usLoss.armor),
vehicles: lerp(0, usLoss.vehicles),
drones: lerp(0, usLoss.drones ?? 0),
missiles: lerp(0, usLoss.missiles ?? 0),
helicopters: lerp(0, usLoss.helicopters ?? 0),
submarines: lerp(0, usLoss.submarines ?? 0),
carriers: lerp(0, usLoss.carriers ?? 0),
civilianShips: lerp(0, usLoss.civilianShips ?? 0),
airportPort: lerp(0, usLoss.airportPort ?? 0),
aircraft: lerpUs(0, usLoss.aircraft),
warships: lerpUs(0, usLoss.warships),
armor: lerpUs(0, usLoss.armor),
vehicles: lerpUs(0, usLoss.vehicles),
drones: lerpUs(0, usLoss.drones ?? 0),
missiles: lerpUs(0, usLoss.missiles ?? 0),
helicopters: lerpUs(0, usLoss.helicopters ?? 0),
submarines: lerpUs(0, usLoss.submarines ?? 0),
carriers: lerpUs(0, usLoss.carriers ?? 0),
civilianShips: lerpUs(0, usLoss.civilianShips ?? 0),
airportPort: lerpUs(0, usLoss.airportPort ?? 0),
}
const irLossesAt = {
bases: {
destroyed: lerp(0, irLoss.bases.destroyed),
damaged: lerp(0, irLoss.bases.damaged),
destroyed: lerpIran(0, irLoss.bases.destroyed),
damaged: lerpIran(0, irLoss.bases.damaged),
},
personnelCasualties: {
killed: lerp(0, irLoss.personnelCasualties.killed),
wounded: lerp(0, irLoss.personnelCasualties.wounded),
killed: lerpIran(0, irLoss.personnelCasualties.killed),
wounded: lerpIran(0, irLoss.personnelCasualties.wounded),
},
civilianCasualties: { killed: 0, wounded: 0 },
aircraft: lerp(0, irLoss.aircraft),
warships: lerp(0, irLoss.warships),
armor: lerp(0, irLoss.armor),
vehicles: lerp(0, irLoss.vehicles),
drones: lerp(0, irLoss.drones ?? 0),
missiles: lerp(0, irLoss.missiles ?? 0),
helicopters: lerp(0, irLoss.helicopters ?? 0),
submarines: lerp(0, irLoss.submarines ?? 0),
carriers: lerp(0, irLoss.carriers ?? 0),
civilianShips: lerp(0, irLoss.civilianShips ?? 0),
airportPort: lerp(0, irLoss.airportPort ?? 0),
aircraft: lerpIran(0, irLoss.aircraft),
warships: lerpIran(0, irLoss.warships),
armor: lerpIran(0, irLoss.armor),
vehicles: lerpIran(0, irLoss.vehicles),
drones: lerpIran(0, irLoss.drones ?? 0),
missiles: lerpIran(0, irLoss.missiles ?? 0),
helicopters: lerpIran(0, irLoss.helicopters ?? 0),
submarines: lerpIran(0, irLoss.submarines ?? 0),
carriers: lerpIran(0, irLoss.carriers ?? 0),
civilianShips: lerpIran(0, irLoss.civilianShips ?? 0),
airportPort: lerpIran(0, irLoss.airportPort ?? 0),
}
// 被袭基地:按 damage_level 排序,高损毁先出现;根据 progress 决定显示哪些为 attacked
// 被袭基地:按 DB attacked_at 过滤,只显示已到回放时刻的遭袭点
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)) {
if (loc.status === 'attacked' && !atOrBefore(loc.attacked_at, playbackTime)) {
return { ...loc, status: 'operational' as const }
}
return { ...loc }
})
const irLocs = situation.iranForces.keyLocations || []
const irLocsAt = irLocs.map((loc) => {
if ((loc.status === 'attacked' || loc.status === 'damaged') && !atOrBefore(loc.attacked_at, playbackTime)) {
return { ...loc, status: 'operational' as const }
}
return { ...loc }
})
// mapData按 struck_at / attacked_at 过滤;打击线固定顺序 以色列 → 林肯 → 福特,保证回放时以色列动画先出现
const mapData = situation.mapData
const mapDataAt = mapData
? (() => {
const filtered = mapData.strikeLines.map((line) => ({
sourceId: line.sourceId,
targets: line.targets.filter((t) => atOrBefore(t.struck_at, playbackTime)),
}))
const order = ['israel', 'lincoln', 'ford']
const ordered = order.map((id) => filtered.find((l) => l.sourceId === id)).filter(Boolean) as typeof filtered
const rest = filtered.filter((l) => !order.includes(l.sourceId))
const strikeLines = [...ordered, ...rest]
return {
attackedTargets: (usLocsAt.filter((l) => l.status === 'attacked') as { lng: number; lat: number }[]).map(
(l) => [l.lng, l.lat] as [number, number]
),
strikeSources: mapData.strikeSources,
strikeLines,
}
})()
: undefined
return {
...situation,
lastUpdated: playbackTime,
civilianCasualtiesTotal: {
killed: lerp(0, civTotal.killed),
wounded: lerp(0, civTotal.wounded),
killed: lerpIran(0, civTotal.killed),
wounded: lerpIran(0, civTotal.wounded),
},
usForces: {
...situation.usForces,
@@ -143,6 +178,7 @@ export function useReplaySituation(): MilitarySituation {
},
iranForces: {
...situation.iranForces,
keyLocations: irLocsAt,
combatLosses: irLossesAt,
retaliationSentiment: retValue,
retaliationSentimentHistory: [
@@ -153,8 +189,11 @@ export function useReplaySituation(): MilitarySituation {
recentUpdates: (situation.recentUpdates || []).filter(
(u) => new Date(u.timestamp).getTime() <= new Date(playbackTime).getTime()
),
conflictEvents: situation.conflictEvents || [],
conflictEvents: (situation.conflictEvents || []).filter(
(e) => new Date(e.event_time).getTime() <= new Date(playbackTime).getTime()
),
conflictStats: situation.conflictStats || { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
mapData: mapDataAt,
}
}, [situation, isReplayMode, playbackTime])
}

View File

@@ -61,5 +61,16 @@ body,
to { transform: translateX(-50%); }
}
@keyframes likePop {
from {
opacity: 1;
transform: scale(1.2);
}
to {
opacity: 0;
transform: scale(1.5) translateY(-8px);
}
}
/* 移动端横屏使用单列+滚动,不再做 zoom 缩放,保持比例正常 */

View File

@@ -190,13 +190,7 @@ export function EditDashboard() {
if (!confirm('确定清除所有覆盖?将恢复为实时统计(在看=近2分钟访问数看过=累计访问等)。')) return
setSaving('displayStats')
try {
await putDisplayStats({
viewers: null,
cumulative: null,
shareCount: null,
likeCount: null,
feedbackCount: null,
})
await putDisplayStats({ clearOverride: true })
await load()
const res = await fetch('/api/stats', { cache: 'no-store' })
if (res.ok) {
@@ -263,10 +257,13 @@ export function EditDashboard() {
{openSections.has('displayStats') && data && (
<div className="border-t border-military-border p-4 space-y-3">
<p className="text-military-text-secondary text-xs">
= 2 ID = 访
{data.displayStats?.overrideEnabled
? '当前看板显示为覆盖值。'
: '当前看板显示为实时统计。'}
= 2 访 = 访
</p>
<DisplayStatsForm
row={data.displayStats ?? { viewers: 0, cumulative: 0, shareCount: 0, likeCount: 0, feedbackCount: 0 }}
row={data.displayStats ?? { overrideEnabled: false, viewers: 0, cumulative: 0, shareCount: 0, likeCount: 0, feedbackCount: 0 }}
onSave={handleSaveDisplayStats}
onClearOverrides={handleClearDisplayStatsOverrides}
saving={saving === 'displayStats'}

View File

@@ -1,39 +1,51 @@
import { create } from 'zustand'
const REPLAY_DAY = '2026-03-01'
const TICK_MS = 30 * 60 * 1000 // 30 minutes
/** 回放日期范围:覆盖 2 月 28 日(盟军打击伊朗 → 伊朗反击),与 DB attacked_at / struck_at 对齐 */
const REPLAY_START_ISO = '2026-02-28T00:00:00.000Z'
const REPLAY_END_ISO = '2026-03-01T23:30:00.000Z'
export const REPLAY_START = `${REPLAY_DAY}T00:00:00.000Z`
export const REPLAY_END = `${REPLAY_DAY}T23:30:00.000Z`
export const REPLAY_START = REPLAY_START_ISO
export const REPLAY_END = REPLAY_END_ISO
export type ReplayScale = '30m' | '1h' | '1d'
const SCALE_MS: Record<ReplayScale, number> = {
'30m': 30 * 60 * 1000,
'1h': 60 * 60 * 1000,
'1d': 24 * 60 * 60 * 1000,
}
function parseTime(iso: string): number {
return new Date(iso).getTime()
}
export function getTicks(): string[] {
/** 按刻度生成回放时间点;不传 scale 时默认 30 分钟 */
export function getTicks(scale: ReplayScale = '30m'): string[] {
const step = SCALE_MS[scale]
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
t += step
}
return ticks
}
export const REPLAY_TICKS = getTicks()
export interface PlaybackState {
/** 是否开启回放模式 */
isReplayMode: boolean
/** 当前回放时刻 (ISO) */
playbackTime: string
/** 回放刻度30分钟 / 1小时 / 1天 */
replayScale: ReplayScale
/** 是否正在自动播放 */
isPlaying: boolean
/** 播放速度 (秒/刻度) */
speedSecPerTick: number
setReplayMode: (v: boolean) => void
setPlaybackTime: (iso: string) => void
setReplayScale: (scale: ReplayScale) => void
setIsPlaying: (v: boolean) => void
stepForward: () => void
stepBack: () => void
@@ -43,13 +55,20 @@ export interface PlaybackState {
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
isReplayMode: false,
playbackTime: REPLAY_END,
replayScale: '30m',
isPlaying: false,
speedSecPerTick: 2,
setReplayMode: (v) => set({ isReplayMode: v, isPlaying: false }),
setReplayMode: (v) =>
set({
isReplayMode: v,
isPlaying: false,
...(v ? { playbackTime: REPLAY_START } : {}),
}),
setPlaybackTime: (iso) => {
const ticks = REPLAY_TICKS
const { replayScale } = get()
const ticks = getTicks(replayScale)
if (ticks.includes(iso)) {
set({ playbackTime: iso })
return
@@ -59,19 +78,27 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
set({ playbackTime: ticks[clamp] })
},
setReplayScale: (scale) => {
const { playbackTime } = get()
const ticks = getTicks(scale)
const idx = ticks.findIndex((t) => t >= playbackTime)
const clamp = Math.max(0, Math.min(idx < 0 ? ticks.length - 1 : idx, ticks.length - 1))
set({ replayScale: scale, playbackTime: ticks[clamp] })
},
setIsPlaying: (v) => set({ isPlaying: v }),
stepForward: () => {
const { playbackTime } = get()
const ticks = REPLAY_TICKS
const { playbackTime, replayScale } = get()
const ticks = getTicks(replayScale)
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 { playbackTime, replayScale } = get()
const ticks = getTicks(replayScale)
const i = ticks.indexOf(playbackTime)
if (i > 0) set({ playbackTime: ticks[i - 1] })
},

View File

@@ -52,6 +52,7 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
const POLL_INTERVAL_MS = 3000
// situation.lastUpdated 与后端 situation.updated_at 一致,后端在爬虫 notify、编辑保存时更新并广播
function pollSituation() {
fetchSituation()
.then((situation) => useSituationStore.getState().setSituation(situation))