fix:优化整个大屏界面
This commit is contained in:
@@ -7,6 +7,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"api": "node server/index.js",
|
"api": "node server/index.js",
|
||||||
"api:seed": "node server/seed.js",
|
"api:seed": "node server/seed.js",
|
||||||
|
"crawler": "cd crawler && python main.py",
|
||||||
|
"gdelt": "cd crawler && uvicorn realtime_conflict_service:app --host 0.0.0.0 --port 8000",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
27
server/db.js
27
server/db.js
@@ -92,6 +92,26 @@ db.exec(`
|
|||||||
summary TEXT NOT NULL,
|
summary TEXT NOT NULL,
|
||||||
severity TEXT NOT NULL
|
severity TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS gdelt_events (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
event_time TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lng REAL NOT NULL,
|
||||||
|
impact_score INTEGER NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS conflict_stats (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total_events INTEGER NOT NULL DEFAULT 0,
|
||||||
|
high_impact_events INTEGER NOT NULL DEFAULT 0,
|
||||||
|
estimated_casualties INTEGER NOT NULL DEFAULT 0,
|
||||||
|
estimated_strike_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// 迁移:为已有 key_location 表添加 type、region、status、damage_level 列
|
// 迁移:为已有 key_location 表添加 type、region、status、damage_level 列
|
||||||
@@ -103,5 +123,12 @@ try {
|
|||||||
if (!names.includes('status')) db.exec('ALTER TABLE key_location ADD COLUMN status TEXT DEFAULT "operational"')
|
if (!names.includes('status')) db.exec('ALTER TABLE key_location ADD COLUMN status TEXT DEFAULT "operational"')
|
||||||
if (!names.includes('damage_level')) db.exec('ALTER TABLE key_location ADD COLUMN damage_level INTEGER')
|
if (!names.includes('damage_level')) db.exec('ALTER TABLE key_location ADD COLUMN damage_level INTEGER')
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
// 迁移:combat_losses 添加平民伤亡
|
||||||
|
try {
|
||||||
|
const lossCols = db.prepare('PRAGMA table_info(combat_losses)').all()
|
||||||
|
const lossNames = lossCols.map((c) => c.name)
|
||||||
|
if (!lossNames.includes('civilian_killed')) db.exec('ALTER TABLE combat_losses ADD COLUMN civilian_killed INTEGER NOT NULL DEFAULT 0')
|
||||||
|
if (!lossNames.includes('civilian_wounded')) db.exec('ALTER TABLE combat_losses ADD COLUMN civilian_wounded INTEGER NOT NULL DEFAULT 0')
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
module.exports = db
|
module.exports = db
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ app.use(cors())
|
|||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
app.use('/api', routes)
|
app.use('/api', routes)
|
||||||
app.get('/api/health', (_, res) => res.json({ ok: true }))
|
app.get('/api/health', (_, res) => res.json({ ok: true }))
|
||||||
|
app.post('/api/crawler/notify', (_, res) => {
|
||||||
|
notifyCrawlerUpdate()
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
const server = http.createServer(app)
|
const server = http.createServer(app)
|
||||||
|
|
||||||
@@ -29,6 +33,15 @@ function broadcastSituation() {
|
|||||||
}
|
}
|
||||||
setInterval(broadcastSituation, 5000)
|
setInterval(broadcastSituation, 5000)
|
||||||
|
|
||||||
|
// 供爬虫调用:更新 situation.updated_at 并立即广播
|
||||||
|
function notifyCrawlerUpdate() {
|
||||||
|
try {
|
||||||
|
const db = require('./db')
|
||||||
|
db.prepare("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)").run(new Date().toISOString())
|
||||||
|
broadcastSituation()
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`API + WebSocket running at http://localhost:${PORT}`)
|
console.log(`API + WebSocket running at http://localhost:${PORT}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,4 +12,19 @@ router.get('/situation', (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/events', (req, res) => {
|
||||||
|
try {
|
||||||
|
const s = getSituation()
|
||||||
|
res.json({
|
||||||
|
updated_at: s.lastUpdated,
|
||||||
|
count: (s.conflictEvents || []).length,
|
||||||
|
events: s.conflictEvents || [],
|
||||||
|
conflict_stats: s.conflictStats || {},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -140,11 +140,19 @@ function seed() {
|
|||||||
]
|
]
|
||||||
iranLocs.forEach((r) => insertLoc.run(...r))
|
iranLocs.forEach((r) => insertLoc.run(...r))
|
||||||
|
|
||||||
db.exec(`
|
try {
|
||||||
INSERT OR REPLACE INTO combat_losses (side, bases_destroyed, bases_damaged, personnel_killed, personnel_wounded, aircraft, warships, armor, vehicles) VALUES
|
db.exec(`
|
||||||
('us', 0, 27, 127, 384, 2, 0, 0, 8),
|
INSERT OR REPLACE INTO combat_losses (side, bases_destroyed, bases_damaged, personnel_killed, personnel_wounded, civilian_killed, civilian_wounded, aircraft, warships, armor, vehicles) VALUES
|
||||||
('iran', 3, 8, 2847, 5620, 24, 12, 18, 42);
|
('us', 0, 27, 127, 384, 18, 52, 2, 0, 0, 8),
|
||||||
`)
|
('iran', 3, 8, 2847, 5620, 412, 1203, 24, 12, 18, 42);
|
||||||
|
`)
|
||||||
|
} catch (_) {
|
||||||
|
db.exec(`
|
||||||
|
INSERT OR REPLACE INTO combat_losses (side, bases_destroyed, bases_damaged, personnel_killed, personnel_wounded, aircraft, warships, armor, vehicles) VALUES
|
||||||
|
('us', 0, 27, 127, 384, 2, 0, 0, 8),
|
||||||
|
('iran', 3, 8, 2847, 5620, 24, 12, 18, 42);
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
db.exec('DELETE FROM wall_street_trend')
|
db.exec('DELETE FROM wall_street_trend')
|
||||||
const trendRows = [['2025-03-01T00:00:00', 82], ['2025-03-01T03:00:00', 85], ['2025-03-01T06:00:00', 88], ['2025-03-01T09:00:00', 90], ['2025-03-01T12:00:00', 92], ['2025-03-01T15:00:00', 94], ['2025-03-01T18:00:00', 95], ['2025-03-01T21:00:00', 96], ['2025-03-01T23:00:00', 98]]
|
const trendRows = [['2025-03-01T00:00:00', 82], ['2025-03-01T03:00:00', 85], ['2025-03-01T06:00:00', 88], ['2025-03-01T09:00:00', 90], ['2025-03-01T12:00:00', 92], ['2025-03-01T15:00:00', 94], ['2025-03-01T18:00:00', 95], ['2025-03-01T21:00:00', 96], ['2025-03-01T23:00:00', 98]]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function toLosses(row) {
|
|||||||
return {
|
return {
|
||||||
bases: { destroyed: row.bases_destroyed, damaged: row.bases_damaged },
|
bases: { destroyed: row.bases_destroyed, damaged: row.bases_damaged },
|
||||||
personnelCasualties: { killed: row.personnel_killed, wounded: row.personnel_wounded },
|
personnelCasualties: { killed: row.personnel_killed, wounded: row.personnel_wounded },
|
||||||
|
civilianCasualties: { killed: row.civilian_killed ?? 0, wounded: row.civilian_wounded ?? 0 },
|
||||||
aircraft: row.aircraft,
|
aircraft: row.aircraft,
|
||||||
warships: row.warships,
|
warships: row.warships,
|
||||||
armor: row.armor,
|
armor: row.armor,
|
||||||
@@ -25,6 +26,7 @@ function toLosses(row) {
|
|||||||
const defaultLosses = {
|
const defaultLosses = {
|
||||||
bases: { destroyed: 0, damaged: 0 },
|
bases: { destroyed: 0, damaged: 0 },
|
||||||
personnelCasualties: { killed: 0, wounded: 0 },
|
personnelCasualties: { killed: 0, wounded: 0 },
|
||||||
|
civilianCasualties: { killed: 0, wounded: 0 },
|
||||||
aircraft: 0,
|
aircraft: 0,
|
||||||
warships: 0,
|
warships: 0,
|
||||||
armor: 0,
|
armor: 0,
|
||||||
@@ -45,9 +47,30 @@ function getSituation() {
|
|||||||
const trend = db.prepare('SELECT time, value FROM wall_street_trend ORDER BY time').all()
|
const trend = db.prepare('SELECT time, value FROM wall_street_trend ORDER BY time').all()
|
||||||
const retaliationCur = db.prepare('SELECT value FROM retaliation_current WHERE id = 1').get()
|
const retaliationCur = db.prepare('SELECT value FROM retaliation_current WHERE id = 1').get()
|
||||||
const retaliationHist = db.prepare('SELECT time, value FROM retaliation_history ORDER BY time').all()
|
const retaliationHist = db.prepare('SELECT time, value FROM retaliation_history ORDER BY time').all()
|
||||||
const updates = db.prepare('SELECT * FROM situation_update ORDER BY timestamp DESC').all()
|
const updates = db.prepare('SELECT * FROM situation_update ORDER BY timestamp DESC LIMIT 50').all()
|
||||||
const meta = db.prepare('SELECT updated_at FROM situation WHERE id = 1').get()
|
const meta = db.prepare('SELECT updated_at FROM situation WHERE id = 1').get()
|
||||||
|
|
||||||
|
let conflictEvents = []
|
||||||
|
let conflictStats = { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 }
|
||||||
|
try {
|
||||||
|
conflictEvents = db.prepare('SELECT event_id, event_time, title, lat, lng, impact_score, url FROM gdelt_events ORDER BY event_time DESC LIMIT 30').all()
|
||||||
|
const statsRow = db.prepare('SELECT total_events, high_impact_events, estimated_casualties, estimated_strike_count FROM conflict_stats WHERE id = 1').get()
|
||||||
|
if (statsRow) conflictStats = statsRow
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// 根据爬虫 conflict_stats 实时合并平民伤亡估算(GDELT 数据)
|
||||||
|
const usLossesBase = lossesUs ? toLosses(lossesUs) : defaultLosses
|
||||||
|
const irLossesBase = lossesIr ? toLosses(lossesIr) : defaultLosses
|
||||||
|
const est = conflictStats.estimated_casualties || 0
|
||||||
|
const mergeCivilian = (base, share) => {
|
||||||
|
if (est <= 0) return base.civilianCasualties || { killed: 0, wounded: 0 }
|
||||||
|
const gdeltKilled = Math.round(est * share)
|
||||||
|
const cur = base.civilianCasualties || { killed: 0, wounded: 0 }
|
||||||
|
return { killed: Math.max(cur.killed, gdeltKilled), wounded: cur.wounded }
|
||||||
|
}
|
||||||
|
const usLosses = { ...usLossesBase, civilianCasualties: mergeCivilian(usLossesBase, 0.35) }
|
||||||
|
const irLosses = { ...irLossesBase, civilianCasualties: mergeCivilian(irLossesBase, 0.65) }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lastUpdated: meta?.updated_at || new Date().toISOString(),
|
lastUpdated: meta?.updated_at || new Date().toISOString(),
|
||||||
usForces: {
|
usForces: {
|
||||||
@@ -69,7 +92,7 @@ function getSituation() {
|
|||||||
},
|
},
|
||||||
assets: (assetsUs || []).map(toAsset),
|
assets: (assetsUs || []).map(toAsset),
|
||||||
keyLocations: locUs || [],
|
keyLocations: locUs || [],
|
||||||
combatLosses: lossesUs ? toLosses(lossesUs) : defaultLosses,
|
combatLosses: usLosses,
|
||||||
wallStreetInvestmentTrend: trend || [],
|
wallStreetInvestmentTrend: trend || [],
|
||||||
},
|
},
|
||||||
iranForces: {
|
iranForces: {
|
||||||
@@ -91,7 +114,7 @@ function getSituation() {
|
|||||||
},
|
},
|
||||||
assets: (assetsIr || []).map(toAsset),
|
assets: (assetsIr || []).map(toAsset),
|
||||||
keyLocations: locIr || [],
|
keyLocations: locIr || [],
|
||||||
combatLosses: lossesIr ? toLosses(lossesIr) : defaultLosses,
|
combatLosses: irLosses,
|
||||||
retaliationSentiment: retaliationCur?.value ?? 0,
|
retaliationSentiment: retaliationCur?.value ?? 0,
|
||||||
retaliationSentimentHistory: retaliationHist || [],
|
retaliationSentimentHistory: retaliationHist || [],
|
||||||
},
|
},
|
||||||
@@ -102,6 +125,16 @@ function getSituation() {
|
|||||||
summary: u.summary,
|
summary: u.summary,
|
||||||
severity: u.severity,
|
severity: u.severity,
|
||||||
})),
|
})),
|
||||||
|
conflictEvents: conflictEvents.map((e) => ({
|
||||||
|
event_id: e.event_id,
|
||||||
|
event_time: e.event_time,
|
||||||
|
title: e.title,
|
||||||
|
lat: e.lat,
|
||||||
|
lng: e.lng,
|
||||||
|
impact_score: e.impact_score,
|
||||||
|
url: e.url,
|
||||||
|
})),
|
||||||
|
conflictStats,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
Users,
|
|
||||||
Skull,
|
Skull,
|
||||||
Bandage,
|
Bandage,
|
||||||
Plane,
|
Plane,
|
||||||
@@ -8,108 +7,92 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Car,
|
Car,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
|
UserCircle,
|
||||||
|
Activity,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatMillions } from '@/utils/formatNumber'
|
import { formatMillions } from '@/utils/formatNumber'
|
||||||
import type { CombatLosses } from '@/data/mockData'
|
import type { CombatLosses, ConflictStats } from '@/data/mockData'
|
||||||
|
|
||||||
interface CombatLossesPanelProps {
|
interface CombatLossesPanelProps {
|
||||||
usLosses: CombatLosses
|
usLosses: CombatLosses
|
||||||
iranLosses: CombatLosses
|
iranLosses: CombatLosses
|
||||||
|
conflictStats?: ConflictStats | null
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOSS_ITEMS: {
|
export function CombatLossesPanel({ usLosses, iranLosses, conflictStats, className = '' }: CombatLossesPanelProps) {
|
||||||
key: keyof Omit<CombatLosses, 'bases' | 'personnelCasualties'>
|
const civUs = usLosses.civilianCasualties ?? { killed: 0, wounded: 0 }
|
||||||
label: string
|
const civIr = iranLosses.civilianCasualties ?? { killed: 0, wounded: 0 }
|
||||||
icon: typeof Plane
|
const civTotal = { killed: (civUs.killed ?? 0) + (civIr.killed ?? 0), wounded: (civUs.wounded ?? 0) + (civIr.wounded ?? 0) }
|
||||||
iconColor: string
|
|
||||||
}[] = [
|
const otherRows = [
|
||||||
{ key: 'aircraft', label: '战机', icon: Plane, iconColor: 'text-sky-400' },
|
{ label: '平民', icon: UserCircle, iconColor: 'text-amber-400', value: `${formatMillions(civTotal.killed)} / ${formatMillions(civTotal.wounded)}`, noSide: true },
|
||||||
{ key: 'warships', label: '战舰', icon: Ship, iconColor: 'text-blue-500' },
|
{ label: '基地', icon: Building2, iconColor: 'text-amber-500', us: `${usLosses.bases.destroyed}/${usLosses.bases.damaged}`, ir: `${iranLosses.bases.destroyed}/${iranLosses.bases.damaged}` },
|
||||||
{ key: 'armor', label: '装甲', icon: Shield, iconColor: 'text-emerald-500' },
|
{ label: '战机', icon: Plane, iconColor: 'text-sky-400', us: usLosses.aircraft, ir: iranLosses.aircraft },
|
||||||
{ key: 'vehicles', label: '车辆', icon: Car, iconColor: 'text-slate-400' },
|
{ label: '战舰', icon: Ship, iconColor: 'text-blue-500', us: usLosses.warships, ir: iranLosses.warships },
|
||||||
]
|
{ label: '装甲', icon: Shield, iconColor: 'text-emerald-500', us: usLosses.armor, ir: iranLosses.armor },
|
||||||
|
{ label: '车辆', icon: Car, iconColor: 'text-slate-400', us: usLosses.vehicles, ir: iranLosses.vehicles },
|
||||||
|
]
|
||||||
|
|
||||||
export function CombatLossesPanel({ usLosses, iranLosses, className = '' }: CombatLossesPanelProps) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`flex min-h-[220px] max-h-[240px] min-w-0 flex-1 flex-col overflow-hidden rounded border border-military-border bg-military-panel/95 font-orbitron ${className}`}>
|
||||||
className={`
|
<div className="mb-1.5 flex shrink-0 items-center justify-center gap-2 text-[10px] uppercase tracking-wider text-military-text-secondary">
|
||||||
min-w-0 shrink-0 overflow-x-auto overflow-y-hidden border-t border-military-border scrollbar-thin bg-military-panel/95 px-4 py-2 font-orbitron
|
|
||||||
${className}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex items-center gap-1 text-[10px] uppercase tracking-wider text-military-text-secondary">
|
|
||||||
<TrendingDown className="h-2.5 w-2.5 shrink-0 text-amber-400" />
|
<TrendingDown className="h-2.5 w-2.5 shrink-0 text-amber-400" />
|
||||||
战损数据
|
战损
|
||||||
|
{conflictStats && conflictStats.total_events > 0 && (
|
||||||
|
<span className="flex items-center gap-0.5 rounded bg-cyan-950/50 px-1 py-0.5 text-[9px] text-cyan-400">
|
||||||
|
<Activity className="h-2 w-2" />
|
||||||
|
{conflictStats.total_events}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-wrap gap-x-4 gap-y-3 overflow-x-auto text-xs">
|
|
||||||
{/* 基地 */}
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden px-3 pb-2">
|
||||||
<div className="flex shrink-0 min-w-0 flex-col gap-0.5 overflow-visible">
|
{/* 人员伤亡 - 单独容器 */}
|
||||||
<span className="flex shrink-0 items-center gap-1 text-military-text-secondary">
|
<div className="flex shrink-0 flex-col justify-center overflow-hidden rounded-lg border border-red-900/50 bg-red-950/40 px-3 py-2">
|
||||||
<Building2 className="h-3 w-3 shrink-0 text-amber-500" />
|
<div className="mb-1 flex shrink-0 items-center justify-center gap-3 text-[9px] text-military-text-secondary">
|
||||||
基地
|
<span className="flex items-center gap-0.5"><Skull className="h-3 w-3 text-red-500" /> 阵亡</span>
|
||||||
</span>
|
<span className="flex items-center gap-0.5"><Bandage className="h-3 w-3 text-amber-500" /> 受伤</span>
|
||||||
<div className="flex flex-col gap-0.5 tabular-nums">
|
<span className="text-military-text-secondary/70">|</span>
|
||||||
<div className="flex min-w-0 items-baseline gap-1 overflow-hidden" title={`美 毁${usLosses.bases.destroyed} 损${usLosses.bases.damaged}`}>
|
<span>美 : 伊</span>
|
||||||
<span className="shrink-0 text-military-us">美</span>
|
</div>
|
||||||
<span className="truncate">
|
<div className="grid min-w-0 grid-cols-2 gap-x-2 gap-y-1 overflow-hidden text-center tabular-nums sm:gap-x-4">
|
||||||
毁<strong className="text-amber-400">{usLosses.bases.destroyed}</strong>
|
<div className="min-w-0 truncate text-military-us" title={`美: ${formatMillions(usLosses.personnelCasualties.killed)} / ${formatMillions(usLosses.personnelCasualties.wounded)}`}>
|
||||||
损<strong className="text-amber-300">{usLosses.bases.damaged}</strong>
|
<span className="text-base font-bold text-red-500">{formatMillions(usLosses.personnelCasualties.killed)}</span>
|
||||||
</span>
|
<span className="mx-0.5 text-military-text-secondary">/</span>
|
||||||
|
<span className="text-base font-semibold text-amber-500">{formatMillions(usLosses.personnelCasualties.wounded)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 items-baseline gap-1 overflow-hidden" title={`伊 毁${iranLosses.bases.destroyed} 损${iranLosses.bases.damaged}`}>
|
<div className="min-w-0 truncate text-military-iran" title={`伊: ${formatMillions(iranLosses.personnelCasualties.killed)} / ${formatMillions(iranLosses.personnelCasualties.wounded)}`}>
|
||||||
<span className="shrink-0 text-military-iran">伊</span>
|
<span className="text-base font-bold text-red-500">{formatMillions(iranLosses.personnelCasualties.killed)}</span>
|
||||||
<span className="truncate">
|
<span className="mx-0.5 text-military-text-secondary">/</span>
|
||||||
毁<strong className="text-amber-400">{iranLosses.bases.destroyed}</strong>
|
<span className="text-base font-semibold text-amber-500">{formatMillions(iranLosses.personnelCasualties.wounded)}</span>
|
||||||
损<strong className="text-amber-300">{iranLosses.bases.damaged}</strong>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 人员伤亡 */}
|
{/* 其它 - 标签+图标+数字,单独容器 */}
|
||||||
<div className="flex shrink-0 min-w-0 flex-col gap-0.5 overflow-visible">
|
<div className="min-h-0 min-w-0 flex-1 overflow-hidden rounded border border-military-border/50 bg-military-dark/30 px-2 py-1.5">
|
||||||
<span className="flex shrink-0 items-center gap-1 text-military-text-secondary">
|
<div className="mb-1 text-[8px] text-military-text-secondary">美:伊</div>
|
||||||
<Users className="h-3 w-3 shrink-0 text-slate-400" />
|
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 overflow-hidden text-[11px] tabular-nums lg:grid-cols-3">
|
||||||
人员伤亡
|
{otherRows.map(({ label, icon: Icon, iconColor, ...rest }, i) => (
|
||||||
</span>
|
<div key={i} className="flex min-w-0 items-center justify-between gap-1 overflow-hidden">
|
||||||
<div className="flex flex-col gap-0.5 tabular-nums">
|
<span className="flex shrink-0 items-center gap-0.5 text-military-text-primary">
|
||||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1" title={`美 阵亡${formatMillions(usLosses.personnelCasualties.killed)} 受伤${formatMillions(usLosses.personnelCasualties.wounded)}`}>
|
<Icon className={`h-3 w-3 ${iconColor}`} />
|
||||||
<span className="shrink-0 text-military-us">美</span>
|
{label}
|
||||||
<Skull className="h-2.5 w-2.5 shrink-0 text-red-500" />
|
</span>
|
||||||
<strong className="text-red-500">{formatMillions(usLosses.personnelCasualties.killed)}</strong>
|
{'value' in rest ? (
|
||||||
<Bandage className="h-2.5 w-2.5 shrink-0 text-amber-500" />
|
<span className="min-w-0 truncate text-right text-amber-400">{rest.value}</span>
|
||||||
<strong className="text-amber-500">{formatMillions(usLosses.personnelCasualties.wounded)}</strong>
|
) : (
|
||||||
</div>
|
<span className="min-w-0 truncate text-right">
|
||||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1" title={`伊 阵亡${formatMillions(iranLosses.personnelCasualties.killed)} 受伤${formatMillions(iranLosses.personnelCasualties.wounded)}`}>
|
<span className="text-military-us">{rest.us}</span>
|
||||||
<span className="shrink-0 text-military-iran">伊</span>
|
<span className="text-military-text-secondary/60">:</span>
|
||||||
<Skull className="h-2.5 w-2.5 shrink-0 text-red-500" />
|
<span className="text-military-iran">{rest.ir}</span>
|
||||||
<strong className="text-red-500">{formatMillions(iranLosses.personnelCasualties.killed)}</strong>
|
</span>
|
||||||
<Bandage className="h-2.5 w-2.5 shrink-0 text-amber-500" />
|
)}
|
||||||
<strong className="text-amber-500">{formatMillions(iranLosses.personnelCasualties.wounded)}</strong>
|
</div>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 战机 / 战舰 / 装甲 / 车辆 */}
|
|
||||||
{LOSS_ITEMS.map(({ key, label, icon: Icon, iconColor }) => (
|
|
||||||
<div key={key} className="flex shrink-0 min-w-0 flex-col gap-0.5 overflow-visible">
|
|
||||||
<span className="flex shrink-0 items-center gap-1 text-military-text-secondary">
|
|
||||||
<Icon className={`h-3 w-3 shrink-0 ${iconColor}`} />
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-col gap-0.5 tabular-nums">
|
|
||||||
<div className="flex min-w-0 items-baseline gap-1">
|
|
||||||
<span className="shrink-0 text-military-us">美</span>
|
|
||||||
<strong>{usLosses[key]}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 items-baseline gap-1">
|
|
||||||
<span className="shrink-0 text-military-iran">伊</span>
|
|
||||||
<strong>{iranLosses[key]}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { StatCard } from './StatCard'
|
import { StatCard } from './StatCard'
|
||||||
import { useSituationStore } from '@/store/situationStore'
|
import { useSituationStore } from '@/store/situationStore'
|
||||||
|
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
||||||
|
import { usePlaybackStore } from '@/store/playbackStore'
|
||||||
import { Wifi, WifiOff, Clock } from 'lucide-react'
|
import { Wifi, WifiOff, Clock } from 'lucide-react'
|
||||||
|
|
||||||
export function HeaderPanel() {
|
export function HeaderPanel() {
|
||||||
const { situation, isConnected } = useSituationStore()
|
const situation = useReplaySituation()
|
||||||
|
const isConnected = useSituationStore((s) => s.isConnected)
|
||||||
|
const isReplayMode = usePlaybackStore((s) => s.isReplayMode)
|
||||||
const { usForces, iranForces } = situation
|
const { usForces, iranForces } = situation
|
||||||
const [now, setNow] = useState(() => new Date())
|
const [now, setNow] = useState(() => new Date())
|
||||||
|
|
||||||
@@ -47,9 +51,9 @@ export function HeaderPanel() {
|
|||||||
<Clock className="h-4 w-4 shrink-0" />
|
<Clock className="h-4 w-4 shrink-0" />
|
||||||
<span className="min-w-[11rem] tabular-nums">{formatDateTime(now)}</span>
|
<span className="min-w-[11rem] tabular-nums">{formatDateTime(now)}</span>
|
||||||
</div>
|
</div>
|
||||||
{isConnected && (
|
{(isConnected || isReplayMode) && (
|
||||||
<span className="text-[10px] text-green-500/90">
|
<span className={`text-[10px] ${isReplayMode ? 'text-military-accent' : 'text-green-500/90'}`}>
|
||||||
{formatDataTime(situation.lastUpdated)} (实时更新)
|
{formatDataTime(situation.lastUpdated)} {isReplayMode ? '(回放)' : '(实时更新)'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Map, { Source, Layer } from 'react-map-gl'
|
|||||||
import type { MapRef } from 'react-map-gl'
|
import type { MapRef } from 'react-map-gl'
|
||||||
import type { Map as MapboxMap } from 'mapbox-gl'
|
import type { Map as MapboxMap } from 'mapbox-gl'
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||||
import { useSituationStore } from '@/store/situationStore'
|
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
||||||
import {
|
import {
|
||||||
ATTACKED_TARGETS,
|
ATTACKED_TARGETS,
|
||||||
ALLIED_STRIKE_LOCATIONS,
|
ALLIED_STRIKE_LOCATIONS,
|
||||||
@@ -129,8 +129,8 @@ export function WarMap() {
|
|||||||
const lincolnPathsRef = useRef<[number, number][][]>([])
|
const lincolnPathsRef = useRef<[number, number][][]>([])
|
||||||
const fordPathsRef = useRef<[number, number][][]>([])
|
const fordPathsRef = useRef<[number, number][][]>([])
|
||||||
const israelPathsRef = useRef<[number, number][][]>([])
|
const israelPathsRef = useRef<[number, number][][]>([])
|
||||||
const { situation } = useSituationStore()
|
const situation = useReplaySituation()
|
||||||
const { usForces, iranForces } = situation
|
const { usForces, iranForces, conflictEvents = [] } = situation
|
||||||
|
|
||||||
const usLocs = (usForces.keyLocations || []) as KeyLoc[]
|
const usLocs = (usForces.keyLocations || []) as KeyLoc[]
|
||||||
const irLocs = (iranForces.keyLocations || []) as KeyLoc[]
|
const irLocs = (iranForces.keyLocations || []) as KeyLoc[]
|
||||||
@@ -239,6 +239,29 @@ export function WarMap() {
|
|||||||
[attackPaths]
|
[attackPaths]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GDELT 冲突事件:1–3 绿, 4–6 橙闪, 7–10 红脉
|
||||||
|
const { conflictEventsGreen, conflictEventsOrange, conflictEventsRed } = useMemo(() => {
|
||||||
|
const green: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||||
|
const orange: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||||
|
const red: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||||
|
for (const e of conflictEvents) {
|
||||||
|
const score = e.impact_score ?? 1
|
||||||
|
const f: GeoJSON.Feature<GeoJSON.Point> = {
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { event_id: e.event_id, impact_score: score },
|
||||||
|
geometry: { type: 'Point', coordinates: [e.lng, e.lat] },
|
||||||
|
}
|
||||||
|
if (score <= 3) green.push(f)
|
||||||
|
else if (score <= 6) orange.push(f)
|
||||||
|
else red.push(f)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
conflictEventsGreen: { type: 'FeatureCollection' as const, features: green },
|
||||||
|
conflictEventsOrange: { type: 'FeatureCollection' as const, features: orange },
|
||||||
|
conflictEventsRed: { type: 'FeatureCollection' as const, features: red },
|
||||||
|
}
|
||||||
|
}, [conflictEvents])
|
||||||
|
|
||||||
const hideNonBelligerentLabels = (map: MapboxMap) => {
|
const hideNonBelligerentLabels = (map: MapboxMap) => {
|
||||||
const labelLayers = [
|
const labelLayers = [
|
||||||
'country-label',
|
'country-label',
|
||||||
@@ -362,6 +385,20 @@ export function WarMap() {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
// GDELT 橙色 (4–6):闪烁
|
||||||
|
if (map.getLayer('gdelt-events-orange')) {
|
||||||
|
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004)
|
||||||
|
map.setPaintProperty('gdelt-events-orange', 'circle-opacity', blink)
|
||||||
|
}
|
||||||
|
// GDELT 红色 (7–10):脉冲扩散
|
||||||
|
if (map.getLayer('gdelt-events-red-pulse')) {
|
||||||
|
const cycle = 2200
|
||||||
|
const phase = (elapsed % cycle) / cycle
|
||||||
|
const r = 30 * phase
|
||||||
|
const opacity = Math.max(0, 1 - phase * 1.1)
|
||||||
|
map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r)
|
||||||
|
map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
animRef.current = requestAnimationFrame(tick)
|
animRef.current = requestAnimationFrame(tick)
|
||||||
}
|
}
|
||||||
@@ -376,7 +413,10 @@ export function WarMap() {
|
|||||||
(map.getSource('attack-dots') && attackPathsRef.current.length > 0) ||
|
(map.getSource('attack-dots') && attackPathsRef.current.length > 0) ||
|
||||||
(map.getSource('allied-strike-dots-lincoln') && lincolnPathsRef.current.length > 0) ||
|
(map.getSource('allied-strike-dots-lincoln') && lincolnPathsRef.current.length > 0) ||
|
||||||
(map.getSource('allied-strike-dots-ford') && fordPathsRef.current.length > 0) ||
|
(map.getSource('allied-strike-dots-ford') && fordPathsRef.current.length > 0) ||
|
||||||
(map.getSource('allied-strike-dots-israel') && israelPathsRef.current.length > 0)
|
(map.getSource('allied-strike-dots-israel') && israelPathsRef.current.length > 0) ||
|
||||||
|
map.getSource('gdelt-events-green') ||
|
||||||
|
map.getSource('gdelt-events-orange') ||
|
||||||
|
map.getSource('gdelt-events-red')
|
||||||
if (hasAnim) {
|
if (hasAnim) {
|
||||||
animRef.current = requestAnimationFrame(tick)
|
animRef.current = requestAnimationFrame(tick)
|
||||||
} else {
|
} else {
|
||||||
@@ -439,6 +479,15 @@ export function WarMap() {
|
|||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-[#22D3EE]" /> 以色列打击
|
<span className="h-1.5 w-1.5 rounded-full bg-[#22D3EE]" /> 以色列打击
|
||||||
</span>
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-[#22C55E]" /> 低烈度
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-[#F97316]" /> 中烈度
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-[#EF4444]" /> 高烈度
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Map
|
<Map
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
@@ -537,6 +586,52 @@ export function WarMap() {
|
|||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
|
|
||||||
|
{/* GDELT 冲突事件:1–3 绿点, 4–6 橙闪, 7–10 红脉 */}
|
||||||
|
<Source id="gdelt-events-green" type="geojson" data={conflictEventsGreen}>
|
||||||
|
<Layer
|
||||||
|
id="gdelt-events-green"
|
||||||
|
type="circle"
|
||||||
|
paint={{
|
||||||
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 2, 8, 4, 12, 6],
|
||||||
|
'circle-color': '#22C55E',
|
||||||
|
'circle-stroke-width': 0.5,
|
||||||
|
'circle-stroke-color': '#fff',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
<Source id="gdelt-events-orange" type="geojson" data={conflictEventsOrange}>
|
||||||
|
<Layer
|
||||||
|
id="gdelt-events-orange"
|
||||||
|
type="circle"
|
||||||
|
paint={{
|
||||||
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 5, 12, 8],
|
||||||
|
'circle-color': '#F97316',
|
||||||
|
'circle-opacity': 0.8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
<Source id="gdelt-events-red" type="geojson" data={conflictEventsRed}>
|
||||||
|
<Layer
|
||||||
|
id="gdelt-events-red-dot"
|
||||||
|
type="circle"
|
||||||
|
paint={{
|
||||||
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 2, 8, 4, 12, 6],
|
||||||
|
'circle-color': '#EF4444',
|
||||||
|
'circle-stroke-width': 0.5,
|
||||||
|
'circle-stroke-color': '#fff',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Layer
|
||||||
|
id="gdelt-events-red-pulse"
|
||||||
|
type="circle"
|
||||||
|
paint={{
|
||||||
|
'circle-radius': 0,
|
||||||
|
'circle-color': 'rgba(239, 68, 68, 0.5)',
|
||||||
|
'circle-opacity': 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
|
||||||
{/* 美以联军打击伊朗:路径线 */}
|
{/* 美以联军打击伊朗:路径线 */}
|
||||||
<Source id="allied-strike-lines-lincoln" type="geojson" data={lincolnLinesGeoJson}>
|
<Source id="allied-strike-lines-lincoln" type="geojson" data={lincolnLinesGeoJson}>
|
||||||
<Layer
|
<Layer
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export interface PowerIndex {
|
|||||||
export interface CombatLosses {
|
export interface CombatLosses {
|
||||||
bases: { destroyed: number; damaged: number }
|
bases: { destroyed: number; damaged: number }
|
||||||
personnelCasualties: { killed: number; wounded: number }
|
personnelCasualties: { killed: number; wounded: number }
|
||||||
|
/** 平民伤亡 */
|
||||||
|
civilianCasualties?: { killed: number; wounded: number }
|
||||||
aircraft: number
|
aircraft: number
|
||||||
warships: number
|
warships: number
|
||||||
armor: number
|
armor: number
|
||||||
@@ -45,6 +47,23 @@ export interface SituationUpdate {
|
|||||||
severity: 'low' | 'medium' | 'high' | 'critical'
|
severity: 'low' | 'medium' | 'high' | 'critical'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConflictEvent {
|
||||||
|
event_id: string
|
||||||
|
event_time: string
|
||||||
|
title: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
impact_score: number
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConflictStats {
|
||||||
|
total_events: number
|
||||||
|
high_impact_events: number
|
||||||
|
estimated_casualties: number
|
||||||
|
estimated_strike_count: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface MilitarySituation {
|
export interface MilitarySituation {
|
||||||
lastUpdated: string
|
lastUpdated: string
|
||||||
usForces: {
|
usForces: {
|
||||||
@@ -86,6 +105,10 @@ export interface MilitarySituation {
|
|||||||
retaliationSentimentHistory: { time: string; value: number }[]
|
retaliationSentimentHistory: { time: string; value: number }[]
|
||||||
}
|
}
|
||||||
recentUpdates: SituationUpdate[]
|
recentUpdates: SituationUpdate[]
|
||||||
|
/** GDELT 冲突事件 (1–3 绿点, 4–6 橙闪, 7–10 红脉) */
|
||||||
|
conflictEvents?: ConflictEvent[]
|
||||||
|
/** 战损统计(展示用) */
|
||||||
|
conflictStats?: ConflictStats
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INITIAL_MOCK_DATA: MilitarySituation = {
|
export const INITIAL_MOCK_DATA: MilitarySituation = {
|
||||||
@@ -122,6 +145,7 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
|
|||||||
combatLosses: {
|
combatLosses: {
|
||||||
bases: { destroyed: 0, damaged: 2 },
|
bases: { destroyed: 0, damaged: 2 },
|
||||||
personnelCasualties: { killed: 127, wounded: 384 },
|
personnelCasualties: { killed: 127, wounded: 384 },
|
||||||
|
civilianCasualties: { killed: 18, wounded: 52 },
|
||||||
aircraft: 2,
|
aircraft: 2,
|
||||||
warships: 0,
|
warships: 0,
|
||||||
armor: 0,
|
armor: 0,
|
||||||
@@ -171,6 +195,7 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
|
|||||||
combatLosses: {
|
combatLosses: {
|
||||||
bases: { destroyed: 3, damaged: 8 },
|
bases: { destroyed: 3, damaged: 8 },
|
||||||
personnelCasualties: { killed: 2847, wounded: 5620 },
|
personnelCasualties: { killed: 2847, wounded: 5620 },
|
||||||
|
civilianCasualties: { killed: 412, wounded: 1203 },
|
||||||
aircraft: 24,
|
aircraft: 24,
|
||||||
warships: 12,
|
warships: 12,
|
||||||
armor: 18,
|
armor: 18,
|
||||||
@@ -219,4 +244,6 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
|
|||||||
severity: 'low',
|
severity: 'low',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
conflictEvents: [],
|
||||||
|
conflictStats: { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { HeaderPanel } from '@/components/HeaderPanel'
|
import { HeaderPanel } from '@/components/HeaderPanel'
|
||||||
|
import { TimelinePanel } from '@/components/TimelinePanel'
|
||||||
import { ForcePanel } from '@/components/ForcePanel'
|
import { ForcePanel } from '@/components/ForcePanel'
|
||||||
import { WarMap } from '@/components/WarMap'
|
import { WarMap } from '@/components/WarMap'
|
||||||
import { CombatLossesPanel } from '@/components/CombatLossesPanel'
|
import { CombatLossesPanel } from '@/components/CombatLossesPanel'
|
||||||
|
import { EventTimelinePanel } from '@/components/EventTimelinePanel'
|
||||||
import { BaseStatusPanel } from '@/components/BaseStatusPanel'
|
import { BaseStatusPanel } from '@/components/BaseStatusPanel'
|
||||||
import { PowerChart } from '@/components/PowerChart'
|
import { PowerChart } from '@/components/PowerChart'
|
||||||
import { InvestmentTrendChart } from '@/components/InvestmentTrendChart'
|
import { InvestmentTrendChart } from '@/components/InvestmentTrendChart'
|
||||||
import { RetaliationGauge } from '@/components/RetaliationGauge'
|
import { RetaliationGauge } from '@/components/RetaliationGauge'
|
||||||
import { useSituationStore } from '@/store/situationStore'
|
import { useSituationStore } from '@/store/situationStore'
|
||||||
|
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
||||||
import { fetchAndSetSituation, startSituationWebSocket, stopSituationWebSocket } from '@/store/situationStore'
|
import { fetchAndSetSituation, startSituationWebSocket, stopSituationWebSocket } from '@/store/situationStore'
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const situation = useSituationStore((s) => s.situation)
|
const situation = useReplaySituation()
|
||||||
const isLoading = useSituationStore((s) => s.isLoading)
|
const isLoading = useSituationStore((s) => s.isLoading)
|
||||||
const lastError = useSituationStore((s) => s.lastError)
|
const lastError = useSituationStore((s) => s.lastError)
|
||||||
|
|
||||||
@@ -28,6 +31,7 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<HeaderPanel />
|
<HeaderPanel />
|
||||||
|
<TimelinePanel />
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-auto lg:flex-row lg:overflow-hidden">
|
<div className="flex min-h-0 flex-1 flex-col overflow-auto lg:flex-row lg:overflow-hidden">
|
||||||
<aside className="flex min-h-0 min-w-0 shrink-0 flex-col gap-2 overflow-y-auto overflow-x-visible border-b border-military-border p-3 lg:min-w-[320px] lg:max-w-[340px] lg:border-b-0 lg:border-r lg:p-4">
|
<aside className="flex min-h-0 min-w-0 shrink-0 flex-col gap-2 overflow-y-auto overflow-x-visible border-b border-military-border p-3 lg:min-w-[320px] lg:max-w-[340px] lg:border-b-0 lg:border-r lg:p-4">
|
||||||
@@ -63,8 +67,10 @@ export function Dashboard() {
|
|||||||
<CombatLossesPanel
|
<CombatLossesPanel
|
||||||
usLosses={situation.usForces.combatLosses}
|
usLosses={situation.usForces.combatLosses}
|
||||||
iranLosses={situation.iranForces.combatLosses}
|
iranLosses={situation.iranForces.combatLosses}
|
||||||
className="min-w-0 flex-1 border-t-0"
|
conflictStats={situation.conflictStats}
|
||||||
|
className="min-w-0 flex-1 py-1"
|
||||||
/>
|
/>
|
||||||
|
<EventTimelinePanel updates={situation.recentUpdates} conflictEvents={situation.conflictEvents} className="min-w-0 shrink-0 min-h-[80px] overflow-hidden lg:min-w-[240px]" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user