fix:优化数据样式
This commit is contained in:
BIN
server/data.db
BIN
server/data.db
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,7 @@ import { usePlaybackStore } from '@/store/playbackStore'
|
|||||||
import { Wifi, WifiOff, Clock, Share2, Heart, Eye, MessageSquare } from 'lucide-react'
|
import { Wifi, WifiOff, Clock, Share2, Heart, Eye, MessageSquare } from 'lucide-react'
|
||||||
|
|
||||||
const STORAGE_LIKES = 'us-iran-dashboard-likes'
|
const STORAGE_LIKES = 'us-iran-dashboard-likes'
|
||||||
|
// 冲突时长显示在 TimelinePanel(数据回放栏)内
|
||||||
|
|
||||||
function getStoredLikes(): number {
|
function getStoredLikes(): number {
|
||||||
try {
|
try {
|
||||||
@@ -155,13 +156,15 @@ export function HeaderPanel() {
|
|||||||
return (
|
return (
|
||||||
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 overflow-hidden border-b border-military-border bg-military-panel/95 px-2 py-2 font-orbitron sm:gap-3 sm:px-4 sm:py-3 lg:flex-nowrap lg:gap-4 lg:px-6">
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 overflow-hidden border-b border-military-border bg-military-panel/95 px-2 py-2 font-orbitron sm:gap-3 sm:px-4 sm:py-3 lg:flex-nowrap lg:gap-4 lg:px-6">
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2 sm:gap-3 lg:gap-6">
|
<div className="flex min-w-0 flex-wrap items-center gap-2 sm:gap-3 lg:gap-6">
|
||||||
<h1 className="truncate text-sm font-bold uppercase tracking-wider text-military-accent sm:text-base sm:tracking-widest lg:text-2xl">
|
<h1 className="min-w-0 shrink truncate text-sm font-bold uppercase tracking-wider text-military-accent sm:text-base sm:tracking-widest lg:text-2xl">
|
||||||
美伊军事态势显示
|
美伊军事态势显示
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex min-w-0 shrink-0 flex-col gap-0.5">
|
<div className="flex w-[12rem] shrink-0 flex-col gap-0.5 font-mono sm:w-[13rem]">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-military-text-secondary sm:gap-2 sm:text-sm">
|
<div className="flex items-center gap-1.5 text-xs text-military-text-secondary sm:gap-2 sm:text-sm">
|
||||||
<Clock className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
|
<Clock className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
|
||||||
<span className="tabular-nums sm:min-w-[10rem]">{formatDateTime(now)}</span>
|
<span className="min-w-[10rem] tabular-nums sm:min-w-[11rem]">
|
||||||
|
{formatDateTime(now)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(isConnected || isReplayMode) && (
|
{(isConnected || isReplayMode) && (
|
||||||
<span className={`text-[10px] ${isReplayMode ? 'text-military-accent' : 'text-green-500/90'}`}>
|
<span className={`text-[10px] ${isReplayMode ? 'text-military-accent' : 'text-green-500/90'}`}>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-react'
|
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-react'
|
||||||
import { usePlaybackStore, REPLAY_TICKS, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
|
import { usePlaybackStore, REPLAY_TICKS, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
|
||||||
import { useSituationStore } from '@/store/situationStore'
|
import { useSituationStore } from '@/store/situationStore'
|
||||||
import { NewsTicker } from './NewsTicker'
|
import { NewsTicker } from './NewsTicker'
|
||||||
import { config } from '@/config'
|
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 {
|
function formatTick(iso: string): string {
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
return d.toLocaleString('zh-CN', {
|
return d.toLocaleString('zh-CN', {
|
||||||
@@ -16,8 +19,17 @@ function formatTick(iso: string): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export function TimelinePanel() {
|
||||||
const situation = useSituationStore((s) => s.situation)
|
const situation = useSituationStore((s) => s.situation)
|
||||||
|
const [now, setNow] = useState(() => new Date())
|
||||||
const {
|
const {
|
||||||
isReplayMode,
|
isReplayMode,
|
||||||
playbackTime,
|
playbackTime,
|
||||||
@@ -33,6 +45,14 @@ export function TimelinePanel() {
|
|||||||
|
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isPlaying || !isReplayMode) {
|
if (!isPlaying || !isReplayMode) {
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
@@ -64,8 +84,18 @@ export function TimelinePanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 border-b border-military-border bg-military-panel/95 px-3 py-2">
|
<div className="relative shrink-0 border-b border-military-border bg-military-panel/95 px-3 py-2">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
{!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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setReplayMode(!isReplayMode)}
|
onClick={() => setReplayMode(!isReplayMode)}
|
||||||
|
|||||||
@@ -316,12 +316,12 @@ export function WarMap() {
|
|||||||
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.003)
|
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.003)
|
||||||
map.setPaintProperty('points-damaged', 'circle-opacity', blink)
|
map.setPaintProperty('points-damaged', 'circle-opacity', blink)
|
||||||
}
|
}
|
||||||
// attacked: 红色脉冲 2s 循环, 半径随 zoom 缩放
|
// attacked: 红色脉冲 2s 循环, 半径随 zoom 缩放;phase/r/opacity 钳位避免浮点或取模越界
|
||||||
if (map.getLayer('points-attacked-pulse')) {
|
if (map.getLayer('points-attacked-pulse')) {
|
||||||
const cycle = 2000
|
const cycle = 2000
|
||||||
const phase = (elapsed % cycle) / cycle
|
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
||||||
const r = 40 * phase * zoomScale
|
const r = Math.max(0, 40 * phase * zoomScale)
|
||||||
const opacity = 1 - phase
|
const opacity = Math.min(1, Math.max(0, 1 - phase))
|
||||||
map.setPaintProperty('points-attacked-pulse', 'circle-radius', r)
|
map.setPaintProperty('points-attacked-pulse', 'circle-radius', r)
|
||||||
map.setPaintProperty('points-attacked-pulse', 'circle-opacity', opacity)
|
map.setPaintProperty('points-attacked-pulse', 'circle-opacity', opacity)
|
||||||
}
|
}
|
||||||
@@ -385,12 +385,12 @@ export function WarMap() {
|
|||||||
)
|
)
|
||||||
israelSrc.setData({ type: 'FeatureCollection', features })
|
israelSrc.setData({ type: 'FeatureCollection', features })
|
||||||
}
|
}
|
||||||
// 伊朗被打击目标:蓝色脉冲 (2s 周期), 半径随 zoom 缩放
|
// 伊朗被打击目标:蓝色脉冲 (2s 周期), 半径随 zoom 缩放;phase/r/opacity 钳位
|
||||||
if (map.getLayer('allied-strike-targets-pulse')) {
|
if (map.getLayer('allied-strike-targets-pulse')) {
|
||||||
const cycle = 2000
|
const cycle = 2000
|
||||||
const phase = (elapsed % cycle) / cycle
|
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
||||||
const r = 35 * phase * zoomScale
|
const r = Math.max(0, 35 * phase * zoomScale)
|
||||||
const opacity = Math.max(0, 1 - phase * 1.2)
|
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.2))
|
||||||
map.setPaintProperty('allied-strike-targets-pulse', 'circle-radius', r)
|
map.setPaintProperty('allied-strike-targets-pulse', 'circle-radius', r)
|
||||||
map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity)
|
map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity)
|
||||||
}
|
}
|
||||||
@@ -399,12 +399,12 @@ export function WarMap() {
|
|||||||
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004)
|
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004)
|
||||||
map.setPaintProperty('gdelt-events-orange', 'circle-opacity', blink)
|
map.setPaintProperty('gdelt-events-orange', 'circle-opacity', blink)
|
||||||
}
|
}
|
||||||
// GDELT 红色 (7–10):脉冲扩散, 半径随 zoom 缩放
|
// GDELT 红色 (7–10):脉冲扩散, 半径随 zoom 缩放;phase/r/opacity 钳位
|
||||||
if (map.getLayer('gdelt-events-red-pulse')) {
|
if (map.getLayer('gdelt-events-red-pulse')) {
|
||||||
const cycle = 2200
|
const cycle = 2200
|
||||||
const phase = (elapsed % cycle) / cycle
|
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
||||||
const r = 30 * phase * zoomScale
|
const r = Math.max(0, 30 * phase * zoomScale)
|
||||||
const opacity = Math.max(0, 1 - phase * 1.1)
|
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.1))
|
||||||
map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r)
|
map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r)
|
||||||
map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)
|
map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user