diff --git a/package.json b/package.json index ac37a3a..887f2ae 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "dev": "vite", "api": "node server/index.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", "typecheck": "tsc --noEmit", "lint": "eslint .", diff --git a/server/data.db-shm b/server/data.db-shm index fe9ac28..5be23b9 100644 Binary files a/server/data.db-shm and b/server/data.db-shm differ diff --git a/server/data.db-wal b/server/data.db-wal index e69de29..5fac6c7 100644 Binary files a/server/data.db-wal and b/server/data.db-wal differ diff --git a/server/db.js b/server/db.js index 8ffe1e7..e9e8af6 100644 --- a/server/db.js +++ b/server/db.js @@ -92,6 +92,26 @@ db.exec(` summary 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 列 @@ -103,5 +123,12 @@ try { 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') } 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 diff --git a/server/index.js b/server/index.js index 69462dd..dff9335 100644 --- a/server/index.js +++ b/server/index.js @@ -12,6 +12,10 @@ app.use(cors()) app.use(express.json()) app.use('/api', routes) 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) @@ -29,6 +33,15 @@ function broadcastSituation() { } 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, () => { console.log(`API + WebSocket running at http://localhost:${PORT}`) }) diff --git a/server/routes.js b/server/routes.js index 25aa043..b4cab2a 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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 diff --git a/server/seed.js b/server/seed.js index 7d88f28..ffbd74a 100644 --- a/server/seed.js +++ b/server/seed.js @@ -140,11 +140,19 @@ function seed() { ] iranLocs.forEach((r) => insertLoc.run(...r)) - 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); - `) + try { + db.exec(` + INSERT OR REPLACE INTO combat_losses (side, bases_destroyed, bases_damaged, personnel_killed, personnel_wounded, civilian_killed, civilian_wounded, aircraft, warships, armor, vehicles) VALUES + ('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') 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]] diff --git a/server/situationData.js b/server/situationData.js index 8852e37..770f556 100644 --- a/server/situationData.js +++ b/server/situationData.js @@ -15,6 +15,7 @@ function toLosses(row) { return { bases: { destroyed: row.bases_destroyed, damaged: row.bases_damaged }, personnelCasualties: { killed: row.personnel_killed, wounded: row.personnel_wounded }, + civilianCasualties: { killed: row.civilian_killed ?? 0, wounded: row.civilian_wounded ?? 0 }, aircraft: row.aircraft, warships: row.warships, armor: row.armor, @@ -25,6 +26,7 @@ function toLosses(row) { const defaultLosses = { bases: { destroyed: 0, damaged: 0 }, personnelCasualties: { killed: 0, wounded: 0 }, + civilianCasualties: { killed: 0, wounded: 0 }, aircraft: 0, warships: 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 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 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() + 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 { lastUpdated: meta?.updated_at || new Date().toISOString(), usForces: { @@ -69,7 +92,7 @@ function getSituation() { }, assets: (assetsUs || []).map(toAsset), keyLocations: locUs || [], - combatLosses: lossesUs ? toLosses(lossesUs) : defaultLosses, + combatLosses: usLosses, wallStreetInvestmentTrend: trend || [], }, iranForces: { @@ -91,7 +114,7 @@ function getSituation() { }, assets: (assetsIr || []).map(toAsset), keyLocations: locIr || [], - combatLosses: lossesIr ? toLosses(lossesIr) : defaultLosses, + combatLosses: irLosses, retaliationSentiment: retaliationCur?.value ?? 0, retaliationSentimentHistory: retaliationHist || [], }, @@ -102,6 +125,16 @@ function getSituation() { summary: u.summary, 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, } } diff --git a/src/components/CombatLossesPanel.tsx b/src/components/CombatLossesPanel.tsx index dff2fbd..a693ccd 100644 --- a/src/components/CombatLossesPanel.tsx +++ b/src/components/CombatLossesPanel.tsx @@ -1,6 +1,5 @@ import { Building2, - Users, Skull, Bandage, Plane, @@ -8,108 +7,92 @@ import { Shield, Car, TrendingDown, + UserCircle, + Activity, } from 'lucide-react' import { formatMillions } from '@/utils/formatNumber' -import type { CombatLosses } from '@/data/mockData' +import type { CombatLosses, ConflictStats } from '@/data/mockData' interface CombatLossesPanelProps { usLosses: CombatLosses iranLosses: CombatLosses + conflictStats?: ConflictStats | null className?: string } -const LOSS_ITEMS: { - key: keyof Omit - label: string - icon: typeof Plane - iconColor: string -}[] = [ - { key: 'aircraft', label: '战机', icon: Plane, iconColor: 'text-sky-400' }, - { key: 'warships', label: '战舰', icon: Ship, iconColor: 'text-blue-500' }, - { key: 'armor', label: '装甲', icon: Shield, iconColor: 'text-emerald-500' }, - { key: 'vehicles', label: '车辆', icon: Car, iconColor: 'text-slate-400' }, -] +export function CombatLossesPanel({ usLosses, iranLosses, conflictStats, className = '' }: CombatLossesPanelProps) { + const civUs = usLosses.civilianCasualties ?? { killed: 0, wounded: 0 } + const civIr = iranLosses.civilianCasualties ?? { killed: 0, wounded: 0 } + const civTotal = { killed: (civUs.killed ?? 0) + (civIr.killed ?? 0), wounded: (civUs.wounded ?? 0) + (civIr.wounded ?? 0) } + + const otherRows = [ + { label: '平民', icon: UserCircle, iconColor: 'text-amber-400', value: `${formatMillions(civTotal.killed)} / ${formatMillions(civTotal.wounded)}`, noSide: true }, + { label: '基地', icon: Building2, iconColor: 'text-amber-500', us: `${usLosses.bases.destroyed}/${usLosses.bases.damaged}`, ir: `${iranLosses.bases.destroyed}/${iranLosses.bases.damaged}` }, + { label: '战机', icon: Plane, iconColor: 'text-sky-400', us: usLosses.aircraft, ir: iranLosses.aircraft }, + { 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 ( -
-
+
+
- 战损数据 + 战损 + {conflictStats && conflictStats.total_events > 0 && ( + + + {conflictStats.total_events} + + )}
-
- {/* 基地 */} -
- - - 基地 - -
-
- - - 毁{usLosses.bases.destroyed} - 损{usLosses.bases.damaged} - + +
+ {/* 人员伤亡 - 单独容器 */} +
+
+ 阵亡 + 受伤 + | + 美 : 伊 +
+
+
+ {formatMillions(usLosses.personnelCasualties.killed)} + / + {formatMillions(usLosses.personnelCasualties.wounded)}
-
- - - 毁{iranLosses.bases.destroyed} - 损{iranLosses.bases.damaged} - +
+ {formatMillions(iranLosses.personnelCasualties.killed)} + / + {formatMillions(iranLosses.personnelCasualties.wounded)}
- {/* 人员伤亡 */} -
- - - 人员伤亡 - -
-
- - - {formatMillions(usLosses.personnelCasualties.killed)} - - {formatMillions(usLosses.personnelCasualties.wounded)} -
-
- - - {formatMillions(iranLosses.personnelCasualties.killed)} - - {formatMillions(iranLosses.personnelCasualties.wounded)} -
+ {/* 其它 - 标签+图标+数字,单独容器 */} +
+
美:伊
+
+ {otherRows.map(({ label, icon: Icon, iconColor, ...rest }, i) => ( +
+ + + {label} + + {'value' in rest ? ( + {rest.value} + ) : ( + + {rest.us} + : + {rest.ir} + + )} +
+ ))}
- - {/* 战机 / 战舰 / 装甲 / 车辆 */} - {LOSS_ITEMS.map(({ key, label, icon: Icon, iconColor }) => ( -
- - - {label} - -
-
- - {usLosses[key]} -
-
- - {iranLosses[key]} -
-
-
- ))}
) diff --git a/src/components/HeaderPanel.tsx b/src/components/HeaderPanel.tsx index 513ed42..ce85945 100644 --- a/src/components/HeaderPanel.tsx +++ b/src/components/HeaderPanel.tsx @@ -1,10 +1,14 @@ import { useState, useEffect } from 'react' import { StatCard } from './StatCard' import { useSituationStore } from '@/store/situationStore' +import { useReplaySituation } from '@/hooks/useReplaySituation' +import { usePlaybackStore } from '@/store/playbackStore' import { Wifi, WifiOff, Clock } from 'lucide-react' 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 [now, setNow] = useState(() => new Date()) @@ -47,9 +51,9 @@ export function HeaderPanel() { {formatDateTime(now)}
- {isConnected && ( - - {formatDataTime(situation.lastUpdated)} (实时更新) + {(isConnected || isReplayMode) && ( + + {formatDataTime(situation.lastUpdated)} {isReplayMode ? '(回放)' : '(实时更新)'} )}
diff --git a/src/components/WarMap.tsx b/src/components/WarMap.tsx index b4a3786..46fdbdb 100644 --- a/src/components/WarMap.tsx +++ b/src/components/WarMap.tsx @@ -3,7 +3,7 @@ import Map, { Source, Layer } from 'react-map-gl' import type { MapRef } from 'react-map-gl' import type { Map as MapboxMap } from 'mapbox-gl' import 'mapbox-gl/dist/mapbox-gl.css' -import { useSituationStore } from '@/store/situationStore' +import { useReplaySituation } from '@/hooks/useReplaySituation' import { ATTACKED_TARGETS, ALLIED_STRIKE_LOCATIONS, @@ -129,8 +129,8 @@ export function WarMap() { const lincolnPathsRef = useRef<[number, number][][]>([]) const fordPathsRef = useRef<[number, number][][]>([]) const israelPathsRef = useRef<[number, number][][]>([]) - const { situation } = useSituationStore() - const { usForces, iranForces } = situation + const situation = useReplaySituation() + const { usForces, iranForces, conflictEvents = [] } = situation const usLocs = (usForces.keyLocations || []) as KeyLoc[] const irLocs = (iranForces.keyLocations || []) as KeyLoc[] @@ -239,6 +239,29 @@ export function WarMap() { [attackPaths] ) + // GDELT 冲突事件:1–3 绿, 4–6 橙闪, 7–10 红脉 + const { conflictEventsGreen, conflictEventsOrange, conflictEventsRed } = useMemo(() => { + const green: GeoJSON.Feature[] = [] + const orange: GeoJSON.Feature[] = [] + const red: GeoJSON.Feature[] = [] + for (const e of conflictEvents) { + const score = e.impact_score ?? 1 + const f: GeoJSON.Feature = { + 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 labelLayers = [ '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-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 (_) {} animRef.current = requestAnimationFrame(tick) } @@ -376,7 +413,10 @@ export function WarMap() { (map.getSource('attack-dots') && attackPathsRef.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-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) { animRef.current = requestAnimationFrame(tick) } else { @@ -439,6 +479,15 @@ export function WarMap() { 以色列打击 + + 低烈度 + + + 中烈度 + + + 高烈度 +
+ {/* GDELT 冲突事件:1–3 绿点, 4–6 橙闪, 7–10 红脉 */} + + + + + + + + + + + {/* 美以联军打击伊朗:路径线 */} s.situation) + const situation = useReplaySituation() const isLoading = useSituationStore((s) => s.isLoading) const lastError = useSituationStore((s) => s.lastError) @@ -28,6 +31,7 @@ export function Dashboard() {
)} +