fix: 优化虫 机制,新增伊朗支援

This commit is contained in:
Daniel
2026-03-06 10:34:52 +08:00
parent 89145a6743
commit 9f2442f2e3
20 changed files with 411 additions and 62 deletions

View File

@@ -26,6 +26,12 @@ MAX_DELTA_PER_MERGE = {
"civilian_ships": 20, "airport_port": 10, "civilian_ships": 20, "airport_port": 10,
} }
# 反击情绪 / 华尔街:合理区间,避免爬虫单条提取 0 或 100 导致指标归零或打满
RETALIATION_SMOOTH_WEIGHT = 0.6 # 当前值权重1 - 此值为新值权重,使更新平滑
RETALIATION_HISTORY_MAX_ROWS = 300 # 反击历史条数上限,供前端曲线与回放使用
WALL_STREET_TREND_MAX_ROWS = 200 # 趋势表保留最近条数,避免无限增长
VALUE_CLAMP_MIN, VALUE_CLAMP_MAX = 1, 99 # 0/100 视为异常,写入前夹在 [1,99]
def _clamp_delta(key: str, value: int) -> int: def _clamp_delta(key: str, value: int) -> int:
"""单次增量上限,避免误提「累计」导致波动""" """单次增量上限,避免误提「累计」导致波动"""
@@ -200,37 +206,68 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
updated = True updated = True
except Exception: except Exception:
pass pass
# retaliation # retaliation:平滑更新,避免单条新闻 0/100 导致指标归零或打满
if "retaliation" in extracted: if "retaliation" in extracted:
r = extracted["retaliation"] r = extracted["retaliation"]
conn.execute("INSERT OR REPLACE INTO retaliation_current (id, value) VALUES (1, ?)", (r["value"],)) raw = max(VALUE_CLAMP_MIN, min(VALUE_CLAMP_MAX, int(r.get("value", 50))))
conn.execute("INSERT INTO retaliation_history (time, value) VALUES (?, ?)", (r["time"], r["value"])) row = conn.execute("SELECT value FROM retaliation_current WHERE id = 1").fetchone()
current = int(row[0]) if row else 50
current = max(VALUE_CLAMP_MIN, min(VALUE_CLAMP_MAX, current))
new_val = round(
RETALIATION_SMOOTH_WEIGHT * current + (1 - RETALIATION_SMOOTH_WEIGHT) * raw
)
new_val = max(VALUE_CLAMP_MIN, min(VALUE_CLAMP_MAX, new_val))
ts = (r.get("time") or datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"))[:25]
conn.execute("INSERT OR REPLACE INTO retaliation_current (id, value) VALUES (1, ?)", (new_val,))
conn.execute("INSERT INTO retaliation_history (time, value) VALUES (?, ?)", (ts, new_val))
n_ret = conn.execute("SELECT COUNT(*) FROM retaliation_history").fetchone()[0]
if n_ret > RETALIATION_HISTORY_MAX_ROWS:
conn.execute(
"DELETE FROM retaliation_history WHERE id IN (SELECT id FROM retaliation_history ORDER BY time ASC LIMIT ?)",
(n_ret - RETALIATION_HISTORY_MAX_ROWS,),
)
updated = True updated = True
# wall_street_trend # wall_street_trend:限幅后写入,并保留最近 N 条避免表无限增长
if "wall_street" in extracted: if "wall_street" in extracted:
w = extracted["wall_street"] w = extracted["wall_street"]
conn.execute("INSERT INTO wall_street_trend (time, value) VALUES (?, ?)", (w["time"], w["value"])) raw = int(w.get("value", 50))
val = max(VALUE_CLAMP_MIN, min(VALUE_CLAMP_MAX, raw))
ts = (w.get("time") or datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"))[:25]
conn.execute("INSERT INTO wall_street_trend (time, value) VALUES (?, ?)", (ts, val))
n = conn.execute("SELECT COUNT(*) FROM wall_street_trend").fetchone()[0]
if n > WALL_STREET_TREND_MAX_ROWS:
conn.execute(
"DELETE FROM wall_street_trend WHERE id IN (SELECT id FROM wall_street_trend ORDER BY time ASC LIMIT ?)",
(n - WALL_STREET_TREND_MAX_ROWS,),
)
updated = True updated = True
# key_location更新双方攻击地点美军基地被打击 side=us伊朗设施被打击 side=iran的 status/damage_level # key_location更新双方攻击地点美军基地被打击 side=us伊朗设施被打击 side=iran的 status/damage_level/attacked_at
event_time = extracted.get("_event_time") or datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
if "key_location_updates" in extracted: if "key_location_updates" in extracted:
try: try:
for u in extracted["key_location_updates"]: for u in extracted["key_location_updates"]:
kw_raw = (u.get("name_keywords") or "").strip() kw_raw = (u.get("name_keywords") or "").strip()
if not kw_raw: if not kw_raw:
continue continue
# 支持 "a|b|c" 或 "a b c" 分隔
kw = [k.strip() for k in kw_raw.replace("|", " ").split() if k.strip()] kw = [k.strip() for k in kw_raw.replace("|", " ").split() if k.strip()]
side = u.get("side") side = u.get("side")
status = (u.get("status") or "attacked")[:20] status = (u.get("status") or "attacked")[:20]
dmg = u.get("damage_level", 2) dmg = u.get("damage_level", 2)
if not kw or side not in ("us", "iran"): if not kw or side not in ("us", "iran"):
continue continue
# 简化name LIKE '%kw%' 对每个关键词 OR 连接,支持中英文 attacked_at = (u.get("attacked_at") or event_time)[:25]
conditions = " OR ".join("name LIKE ?" for _ in kw) conditions = " OR ".join("name LIKE ?" for _ in kw)
params = [status, dmg, side] + [f"%{k}%" for k in kw] params_with_at = [status, dmg, attacked_at, side] + [f"%{k}%" for k in kw]
try:
cur = conn.execute(
f"UPDATE key_location SET status=?, damage_level=?, attacked_at=? WHERE side=? AND ({conditions})",
params_with_at,
)
except sqlite3.OperationalError:
params_no_at = [status, dmg, side] + [f"%{k}%" for k in kw]
cur = conn.execute( cur = conn.execute(
f"UPDATE key_location SET status=?, damage_level=? WHERE side=? AND ({conditions})", f"UPDATE key_location SET status=?, damage_level=? WHERE side=? AND ({conditions})",
params, params_no_at,
) )
if cur.rowcount > 0: if cur.rowcount > 0:
updated = True updated = True

View File

@@ -51,6 +51,7 @@ def _call_ollama_extract(text: str, timeout: int = 15) -> Optional[Dict[str, Any
- retaliation_sentiment: 0-100仅当报道涉及伊朗报复/反击情绪时 - retaliation_sentiment: 0-100仅当报道涉及伊朗报复/反击情绪时
- wall_street_value: 0-100仅当报道涉及美股/市场时 - wall_street_value: 0-100仅当报道涉及美股/市场时
- key_location_updates: **双方攻击地点**。每项 {{ "name_keywords": "阿萨德|asad|al-asad", "side": "us或iran被打击方", "status": "attacked", "damage_level": 1-3 }}。美军基地例:阿萨德|asad、乌代德|udeid、埃尔比勒|erbil、因吉尔利克|incirlik。伊朗例德黑兰|tehran、布什尔|bushehr、伊斯法罕|isfahan、阿巴斯|abbas、纳坦兹|natanz - key_location_updates: **双方攻击地点**。每项 {{ "name_keywords": "阿萨德|asad|al-asad", "side": "us或iran被打击方", "status": "attacked", "damage_level": 1-3 }}。美军基地例:阿萨德|asad、乌代德|udeid、埃尔比勒|erbil、因吉尔利克|incirlik。伊朗例德黑兰|tehran、布什尔|bushehr、伊斯法罕|isfahan、阿巴斯|abbas、纳坦兹|natanz
- map_strike_lines仅当报道为**美/以盟军打击伊朗目标**时): 数组,每项 {{ "source_id": "israel或lincoln或ford", "target_lng": 经度, "target_lat": 纬度, "target_name": "目标名", "struck_at": "ISO时间" }}。目标坐标例纳坦兹51.92,33.67伊斯法罕51.67,32.65德黑兰51.39,35.69布什尔50.83,28.97阿巴斯港56.27,27.18
- **导弹消耗增量**(仅当报道明确提到「发射/消耗 了 X 枚导弹」时填,用于看板导弹消耗累计): us_missile_consumed_delta, iran_missile_consumed_delta本则报道中该方新增消耗枚数整数 - **导弹消耗增量**(仅当报道明确提到「发射/消耗 了 X 枚导弹」时填,用于看板导弹消耗累计): us_missile_consumed_delta, iran_missile_consumed_delta本则报道中该方新增消耗枚数整数
原文: 原文:
@@ -133,6 +134,31 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
}) })
if valid: if valid:
out["key_location_updates"] = valid out["key_location_updates"] = valid
# map_strike_lines盟军打击伊朗目标
if "map_strike_lines" in parsed and isinstance(parsed["map_strike_lines"], list):
valid_lines = []
for line in parsed["map_strike_lines"]:
if not isinstance(line, dict):
continue
sid = str(line.get("source_id") or "").strip().lower()
if sid not in ("israel", "lincoln", "ford"):
continue
try:
lng = float(line.get("target_lng", 0))
lat = float(line.get("target_lat", 0))
except (TypeError, ValueError):
continue
name = str(line.get("target_name") or "")[:200]
struck_at = str(line.get("struck_at") or ts)[:25]
valid_lines.append({
"source_id": sid,
"target_lng": lng,
"target_lat": lat,
"target_name": name or None,
"struck_at": struck_at,
})
if valid_lines:
out["map_strike_lines"] = valid_lines
# force_summary 增量:导弹消耗(看板「导弹消耗」由 force_summary.missile_consumed 提供) # force_summary 增量:导弹消耗(看板「导弹消耗」由 force_summary.missile_consumed 提供)
fs_delta = {} fs_delta = {}
for side_key, side_val in [("us_missile_consumed_delta", "us"), ("iran_missile_consumed_delta", "iran")]: for side_key, side_val in [("us_missile_consumed_delta", "us"), ("iran_missile_consumed_delta", "iran")]:

View File

@@ -42,6 +42,7 @@ def _call_dashscope_extract(text: str, timeout: int = 15) -> Optional[Dict[str,
- retaliation_sentiment: 0-100仅当报道涉及伊朗报复情绪时 - retaliation_sentiment: 0-100仅当报道涉及伊朗报复情绪时
- wall_street_value: 0-100仅当报道涉及美股/市场时) - wall_street_value: 0-100仅当报道涉及美股/市场时)
- key_location_updates: **双方攻击地点**。每项 {{"name_keywords":"阿萨德|asad","side":"us或iran被打击方","status":"attacked","damage_level":1-3}}。美军基地:阿萨德|asad、乌代德|udeid、埃尔比勒|erbil、因吉尔利克|incirlik。伊朗德黑兰|tehran、布什尔|bushehr、伊斯法罕|isfahan、阿巴斯|abbas、纳坦兹|natanz - key_location_updates: **双方攻击地点**。每项 {{"name_keywords":"阿萨德|asad","side":"us或iran被打击方","status":"attacked","damage_level":1-3}}。美军基地:阿萨德|asad、乌代德|udeid、埃尔比勒|erbil、因吉尔利克|incirlik。伊朗德黑兰|tehran、布什尔|bushehr、伊斯法罕|isfahan、阿巴斯|abbas、纳坦兹|natanz
- **map_strike_lines**(仅当报道明确为**美/以盟军打击伊朗或伊朗目标**时): 数组,每项 {{"source_id":"israel或lincoln或ford","target_lng":经度,"target_lat":纬度,"target_name":"目标名如纳坦兹","struck_at":"ISO时间"}}。以色列打击→source_id=israel林肯号→lincoln福特号→ford。目标坐标纳坦兹51.92,33.67伊斯法罕51.67,32.65德黑兰51.39,35.69布什尔50.83,28.97阿巴斯港56.27,27.18
- **导弹消耗增量**(仅当报道明确提到「发射/消耗 了 X 枚导弹」时填): us_missile_consumed_delta, iran_missile_consumed_delta本则该方新增消耗枚数整数 - **导弹消耗增量**(仅当报道明确提到「发射/消耗 了 X 枚导弹」时填): us_missile_consumed_delta, iran_missile_consumed_delta本则该方新增消耗枚数整数
原文: 原文:
@@ -133,4 +134,29 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
if valid: if valid:
out["key_location_updates"] = valid out["key_location_updates"] = valid
if "map_strike_lines" in parsed and isinstance(parsed["map_strike_lines"], list):
valid_lines = []
for line in parsed["map_strike_lines"]:
if not isinstance(line, dict):
continue
sid = str(line.get("source_id") or "").strip().lower()
if sid not in ("israel", "lincoln", "ford"):
continue
try:
lng = float(line.get("target_lng", 0))
lat = float(line.get("target_lat", 0))
except (TypeError, ValueError):
continue
name = str(line.get("target_name") or "")[:200]
struck_at = str(line.get("struck_at") or ts)[:25]
valid_lines.append({
"source_id": sid,
"target_lng": lng,
"target_lat": lat,
"target_name": name or None,
"struck_at": struck_at,
})
if valid_lines:
out["map_strike_lines"] = valid_lines
return out return out

View File

@@ -1,11 +1,24 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
基于规则的新闻数据提取(无需 Ollama 基于规则的新闻数据提取(无需 Ollama
从新闻文本中提取战损、报复情绪等数值,供 db_merge 写入 从新闻文本中提取战损、报复情绪、攻击地点与盟军打击线,供 db_merge 写入
""" """
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional, Tuple
# 伊朗境内常见打击目标: (显示名, 经度, 纬度, 匹配关键词)
IRAN_STRIKE_TARGETS: List[Tuple[str, float, float, str]] = [
("纳坦兹", 51.916, 33.666, "natanz|纳坦兹"),
("伊斯法罕", 51.67, 32.65, "isfahan|esfahan|伊斯法罕"),
("德黑兰", 51.389, 35.689, "tehran|德黑兰"),
("布什尔", 50.83, 28.97, "bushehr|布什尔"),
("阿巴斯港", 56.27, 27.18, "bandar abbas|abbas|阿巴斯|霍尔木兹"),
("克尔曼沙赫", 47.06, 34.31, "kermanshah|克尔曼沙赫"),
("大不里士", 46.29, 38.08, "tabriz|大不里士"),
("卡拉季", 50.99, 35.83, "karaj|卡拉季"),
("米纳布", 57.08, 27.13, "minab|米纳布"),
]
def _first_int(text: str, pattern: str) -> Optional[int]: def _first_int(text: str, pattern: str) -> Optional[int]:
@@ -251,4 +264,30 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
if updates: if updates:
out["key_location_updates"] = updates out["key_location_updates"] = updates
# map_strike_lines盟军以色列/林肯/福特)打击伊朗目标,供地图攻击动画更新
strike_verbs = ("strike" in t or "struck" in t or "strikes" in t or "hit" in t or "attack" in t
or "打击" in (text or "") or "空袭" in (text or "") or "袭击" in (text or ""))
if strike_verbs and ("iran" in t or "伊朗" in (text or "") or any(
any(p in t for p in kw.split("|")) for _n, _lng, _lat, kw in IRAN_STRIKE_TARGETS
)):
source_id = "israel"
if "lincoln" in t or "林肯" in (text or ""):
source_id = "lincoln"
elif "ford" in t or "福特" in (text or ""):
source_id = "ford"
elif ("israel" in t or "idf" in t or "以色列" in (text or "")) and ("us " in t or "american" in t or "pentagon" in t):
source_id = "israel" # 多国时优先以色列
lines = []
for name, lng, lat, kw in IRAN_STRIKE_TARGETS:
if any(p in t for p in kw.split("|")):
lines.append({
"source_id": source_id,
"target_lng": lng,
"target_lat": lat,
"target_name": name,
"struck_at": ts,
})
if lines:
out["map_strike_lines"] = lines
return out return out

View File

@@ -67,6 +67,8 @@ def _extract_and_merge(items: list, db_path: str) -> bool:
except Exception: except Exception:
pass pass
extracted = extract_from_news(text, timestamp=ts) extracted = extract_from_news(text, timestamp=ts)
if ts:
extracted["_event_time"] = ts
if extracted and merge(extracted, db_path=db_path): if extracted and merge(extracted, db_path=db_path):
merged_any = True merged_any = True
return merged_any return merged_any

View File

@@ -2,9 +2,15 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/usa_logo.png" /> <link rel="icon" type="image/png" href="/usa_logo.png" />
<link rel="apple-touch-icon" href="/usa_logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>美伊军事态势显示</title> <title>美伊军事态势显示</title>
<meta property="og:type" content="website" />
<meta property="og:title" content="美伊军事态势显示" />
<meta property="og:image" content="/usa_logo.png" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:image" content="/usa_logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">

BIN
public/usa_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

View File

View File

@@ -310,6 +310,49 @@ function runMigrations(db) {
); );
`) `)
} catch (_) {} } catch (_) {}
// 生产环境可能未跑 seed 或使用旧 seed补齐 war_map_config 与以色列→黎巴嫩打击线,保证攻击动画显示
try {
let row = db.prepare('SELECT 1 FROM war_map_config WHERE id = 1').get()
if (!row) {
const warMapConfig = {
pincerAxes: [
{ start: [43.6, 37.2], end: [46.27, 38.08], name: 'North Pincer (Tabriz)' },
{ start: [45.0, 35.4], end: [46.99, 35.31], name: 'Central Pincer (Sanandaj)' },
{ start: [45.6, 35.2], end: [47.07, 34.31], name: 'South Pincer (Kermanshah)' },
],
israelLebanonAxis: { start: [35.25, 32.95], end: [35.55, 33.45], name: 'Israel → Lebanon' },
defenseLinePath: [[46.27, 38.08], [46.99, 35.31], [47.07, 34.31]],
}
db.prepare(
'INSERT INTO war_map_config (id, config, updated_at) VALUES (1, ?, datetime(\'now\'))'
).run(JSON.stringify(warMapConfig))
}
// 确保 map_strike_source 有 israel否则打击线无法关联
db.prepare(
'INSERT OR IGNORE INTO map_strike_source (id, name, lng, lat) VALUES (\'israel\', \'以色列\', 34.78, 32.08)'
).run()
const israelLebanonTargets = [
[35.5, 33.86, '贝鲁特南郊指挥所', '2026-02-28T14:00:00.000Z'],
[35.32, 33.34, '利塔尼弹药库', '2026-02-28T14:10:00.000Z'],
[36.2, 34.01, '巴勒贝克后勤枢纽', '2026-02-28T14:20:00.000Z'],
[35.19, 33.27, '提尔海岸阵地', '2026-02-28T14:30:00.000Z'],
[36.38, 34.39, '赫尔梅勒无人机阵地', '2026-02-28T14:40:00.000Z'],
]
const insertStrikeLine = db.prepare(
'INSERT INTO map_strike_line (source_id, target_lng, target_lat, target_name, struck_at) VALUES (?, ?, ?, ?, ?)'
)
const hasStrikeLine = db.prepare(
'SELECT 1 FROM map_strike_line WHERE source_id = ? AND target_lng = ? AND target_lat = ?'
)
for (const [lng, lat, name, struckAt] of israelLebanonTargets) {
if (!hasStrikeLine.get('israel', lng, lat)) {
insertStrikeLine.run('israel', lng, lat, name, struckAt)
}
}
} catch (_) {}
} }
async function initDb() { async function initDb() {

View File

@@ -79,6 +79,8 @@ 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()
// 反击情绪无记录时给默认 50避免爬虫未写入时前端显示 0
const retaliationValue = retaliationCur?.value ?? 50
const updates = db.prepare('SELECT * FROM situation_update ORDER BY timestamp DESC LIMIT 50').all() const updates = db.prepare('SELECT * FROM situation_update ORDER BY timestamp DESC LIMIT 50').all()
// 数据更新时间:与前端「实时更新」一致,仅在爬虫 notify / 编辑保存时由 index.js 或 routes 更新 // 数据更新时间:与前端「实时更新」一致,仅在爬虫 notify / 编辑保存时由 index.js 或 routes 更新
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()
@@ -157,7 +159,7 @@ function getSituation() {
assets: (assetsIr || []).map(toAsset), assets: (assetsIr || []).map(toAsset),
keyLocations: locIr || [], keyLocations: locIr || [],
combatLosses: irLosses, combatLosses: irLosses,
retaliationSentiment: retaliationCur?.value ?? 0, retaliationSentiment: retaliationValue,
retaliationSentimentHistory: retaliationHist || [], retaliationSentimentHistory: retaliationHist || [],
}, },
recentUpdates: (updates || []).map((u) => ({ recentUpdates: (updates || []).map((u) => ({

View File

@@ -219,9 +219,35 @@ export function WarMap() {
const situation = useReplaySituation() const situation = useReplaySituation()
const { isReplayMode, playbackTime } = usePlaybackStore() const { isReplayMode, playbackTime } = usePlaybackStore()
const { usForces, iranForces, conflictEvents = [] } = situation const { usForces, iranForces, conflictEvents = [] } = situation
/** 时间衰减基准:回放模式用回放时刻,否则用数据更新时间或当前时间 */ /** 时间衰减基准:回放模式用回放时刻;实时模式用「当前数据中最新 struck_at/attacked_at」无事件时再用 lastUpdated/now。
const referenceTime = * 这样爬虫推送后 lastUpdated 跳到「当前」时,窗口仍以数据中最新事件为右端,不会前移导致已有攻击路线被排除(动画消失)。 */
isReplayMode ? playbackTime : situation.lastUpdated || new Date().toISOString() const referenceTime = useMemo(() => {
if (isReplayMode) return playbackTime
let maxTs = 0
for (const line of situation.mapData?.strikeLines ?? []) {
for (const t of line.targets ?? []) {
if (t.struck_at) {
const ts = new Date(t.struck_at).getTime()
if (ts > maxTs) maxTs = ts
}
}
}
for (const loc of [...(usForces.keyLocations ?? []), ...(iranForces.keyLocations ?? [])]) {
if (loc.attacked_at) {
const ts = new Date(loc.attacked_at).getTime()
if (ts > maxTs) maxTs = ts
}
}
if (maxTs > 0) return new Date(maxTs).toISOString()
return situation.lastUpdated || new Date().toISOString()
}, [
isReplayMode,
playbackTime,
situation.lastUpdated,
situation.mapData?.strikeLines,
usForces.keyLocations,
iranForces.keyLocations,
])
const usLocs = (usForces.keyLocations || []) as KeyLoc[] const usLocs = (usForces.keyLocations || []) as KeyLoc[]
const irLocs = (iranForces.keyLocations || []) as KeyLoc[] const irLocs = (iranForces.keyLocations || []) as KeyLoc[]
@@ -500,9 +526,33 @@ export function WarMap() {
[hormuzTargetPoints, isReplayMode] [hormuzTargetPoints, isReplayMode]
) )
// 霍尔木兹海峡交战区 & 真主党势力范围(静态面) // 霍尔木兹海峡交战区 & 真主党势力范围(静态面);保持引用稳定,避免广播更新时触发 setData 导致轮廓线丢失
const hormuzZone = EXTENDED_WAR_ZONES.hormuzCombatZone const hormuzZone = useMemo(() => EXTENDED_WAR_ZONES.hormuzCombatZone, [])
const hezbollahZone = EXTENDED_WAR_ZONES.hezbollahZone const hezbollahZone = useMemo(() => EXTENDED_WAR_ZONES.hezbollahZone, [])
const hormuzLabelData = useMemo(
() => ({
type: 'Feature' as const,
properties: { name: (EXTENDED_WAR_ZONES.hormuzCombatZone.properties as { name?: string }).name ?? '霍尔木兹海峡交战区' },
geometry: { type: 'Point' as const, coordinates: EXTENDED_WAR_ZONES.hormuzLabelCenter },
}),
[]
)
const hezbollahLabelData = useMemo(
() => ({
type: 'Feature' as const,
properties: { name: (EXTENDED_WAR_ZONES.hezbollahZone.properties as { name?: string }).name ?? '真主党势力范围' },
geometry: { type: 'Point' as const, coordinates: EXTENDED_WAR_ZONES.hezbollahLabelCenter },
}),
[]
)
const kurdishLabelData = useMemo(
() => ({
type: 'Feature' as const,
properties: { name: '库尔德武装' },
geometry: { type: 'Point' as const, coordinates: EXTENDED_WAR_ZONES.kurdishLabelCenter },
}),
[]
)
// GDELT 冲突事件13 绿, 46 橙闪, 710 红脉 // GDELT 冲突事件13 绿, 46 橙闪, 710 红脉
const { conflictEventsGreen, conflictEventsOrange, conflictEventsRed } = useMemo(() => { const { conflictEventsGreen, conflictEventsOrange, conflictEventsRed } = useMemo(() => {
@@ -546,6 +596,8 @@ export function WarMap() {
} }
const initAnimation = useRef<(map: MapboxMap) => void>(null!) const initAnimation = useRef<(map: MapboxMap) => void>(null!)
const [zoneSourceKey, setZoneSourceKey] = useState(0)
const lastCheckedZoneRef = useRef<string>('')
initAnimation.current = (map: MapboxMap) => { initAnimation.current = (map: MapboxMap) => {
startRef.current = performance.now() startRef.current = performance.now()
@@ -837,6 +889,26 @@ export function WarMap() {
return () => ro.disconnect() return () => ro.disconnect()
}, [fitToTheater]) }, [fitToTheater])
// 广播更新后检查轮廓层是否仍在;若被误删则递进 zoneSourceKey 强制轮廓 Source 重新挂载
useEffect(() => {
const lastUpdated = situation.lastUpdated ?? ''
if (lastUpdated === lastCheckedZoneRef.current) return
lastCheckedZoneRef.current = lastUpdated
const map = mapRef.current?.getMap()
if (!map?.isStyleLoaded()) return
const t = setTimeout(() => {
const map2 = mapRef.current?.getMap()
if (!map2) return
const hasKurdish = !!map2.getLayer('kurdish-zones')
const hasHormuz = !!map2.getLayer('hormuz-combat-fill')
const hasHezbollah = !!map2.getLayer('hezbollah-fill')
if (!hasKurdish || !hasHormuz || !hasHezbollah) {
setZoneSourceKey((k) => k + 1)
}
}, 150)
return () => clearTimeout(t)
}, [situation.lastUpdated])
if (!MAPBOX_TOKEN) { if (!MAPBOX_TOKEN) {
return ( return (
<div className="flex h-full w-full items-center justify-center bg-military-dark"> <div className="flex h-full w-full items-center justify-center bg-military-dark">
@@ -877,6 +949,9 @@ 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-sm bg-red-500/40" /> <span className="h-1.5 w-1.5 rounded-sm bg-red-500/40" />
</span> </span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-sm bg-red-500/40" />
</span>
<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-[#3B82F6]" /> <span className="h-1.5 w-1.5 rounded-full bg-[#3B82F6]" />
</span> </span>
@@ -1241,7 +1316,7 @@ export function WarMap() {
</Source> </Source>
{/* 跨国库尔德势力:土(Bakur)/叙(Rojava)/伊(Bashur) 三区 MultiPolygon + 北/南钳形箭头 */} {/* 跨国库尔德势力:土(Bakur)/叙(Rojava)/伊(Bashur) 三区 MultiPolygon + 北/南钳形箭头 */}
<Source id="kurdish-front-source" type="geojson" data={KURDISH_FRONT_GEOJSON}> <Source key={`zone-kurdish-${zoneSourceKey}`} id="kurdish-front-source" type="geojson" data={KURDISH_FRONT_GEOJSON}>
{/* 势力范围:紫色半透明,远景更显、近景更透(描边单独 line 层,参考真主党) */} {/* 势力范围:紫色半透明,远景更显、近景更透(描边单独 line 层,参考真主党) */}
<Layer <Layer
id="kurdish-zones" id="kurdish-zones"
@@ -1686,6 +1761,62 @@ export function WarMap() {
/> />
</Source> </Source>
{/* 苏丹武装势力范围(支持伊朗,伊朗色标轮廓) */}
<Source
id="sudan-area"
type="geojson"
data={EXTENDED_WAR_ZONES.sudanZone}
>
<Layer
id="sudan-fill"
type="fill"
paint={{
'fill-color': 'rgba(239, 68, 68, 0.2)',
'fill-outline-color': 'transparent',
'fill-opacity': 0.4,
}}
/>
</Source>
{/* 苏丹国境线(独立线图层,伊朗红) */}
<Source
id="sudan-border"
type="geojson"
data={EXTENDED_WAR_ZONES.sudanBorderLine}
>
<Layer
id="sudan-border-line"
type="line"
paint={{
'line-color': '#EF4444',
'line-width': 2,
'line-opacity': 1,
}}
/>
</Source>
<Source
id="sudan-label"
type="geojson"
data={{
type: 'Feature',
properties: { name: '苏丹武装' },
geometry: { type: 'Point', coordinates: EXTENDED_WAR_ZONES.sudanLabelCenter },
}}
>
<Layer
id="sudan-label-text"
type="symbol"
layout={{
'text-field': '苏丹武装',
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 12],
'text-anchor': 'center',
}}
paint={{
'text-color': '#EF4444',
'text-halo-width': 0,
}}
/>
</Source>
{/* 伊朗标注 */} {/* 伊朗标注 */}
<Source <Source
id="iran-label" id="iran-label"
@@ -1805,7 +1936,7 @@ export function WarMap() {
</Source> </Source>
{/* 霍尔木兹海峡交战区 - 金黄色 mesh 区域 */} {/* 霍尔木兹海峡交战区 - 金黄色 mesh 区域 */}
<Source id="hormuz-combat-zone" type="geojson" data={hormuzZone}> <Source key={`zone-hormuz-${zoneSourceKey}`} id="hormuz-combat-zone" type="geojson" data={hormuzZone}>
<Layer <Layer
id="hormuz-combat-fill" id="hormuz-combat-fill"
type="fill" type="fill"
@@ -1826,7 +1957,7 @@ export function WarMap() {
</Source> </Source>
{/* 真主党势力范围 - 绿色半透明区域 */} {/* 真主党势力范围 - 绿色半透明区域 */}
<Source id="hezbollah-zone" type="geojson" data={hezbollahZone}> <Source key={`zone-hezbollah-${zoneSourceKey}`} id="hezbollah-zone" type="geojson" data={hezbollahZone}>
<Layer <Layer
id="hezbollah-fill" id="hezbollah-fill"
type="fill" type="fill"
@@ -1846,18 +1977,7 @@ export function WarMap() {
</Source> </Source>
{/* 霍尔木兹海峡区域标注 */} {/* 霍尔木兹海峡区域标注 */}
<Source <Source id="hormuz-label" type="geojson" data={hormuzLabelData}>
id="hormuz-label"
type="geojson"
data={{
type: 'Feature',
properties: { name: (hormuzZone.properties as any).name },
geometry: {
type: 'Point',
coordinates: EXTENDED_WAR_ZONES.hormuzLabelCenter,
},
}}
>
<Layer <Layer
id="hormuz-label-text" id="hormuz-label-text"
type="symbol" type="symbol"
@@ -1876,18 +1996,7 @@ export function WarMap() {
</Source> </Source>
{/* 真主党势力范围标注 */} {/* 真主党势力范围标注 */}
<Source <Source id="hezbollah-label" type="geojson" data={hezbollahLabelData}>
id="hezbollah-label"
type="geojson"
data={{
type: 'Feature',
properties: { name: (hezbollahZone.properties as any).name },
geometry: {
type: 'Point',
coordinates: EXTENDED_WAR_ZONES.hezbollahLabelCenter,
},
}}
>
<Layer <Layer
id="hezbollah-label-text" id="hezbollah-label-text"
type="symbol" type="symbol"
@@ -1906,18 +2015,7 @@ export function WarMap() {
</Source> </Source>
{/* 库尔德武装势力范围标注(参照真主党紫色区域标记) */} {/* 库尔德武装势力范围标注(参照真主党紫色区域标记) */}
<Source <Source id="kurdish-label" type="geojson" data={kurdishLabelData}>
id="kurdish-label"
type="geojson"
data={{
type: 'Feature',
properties: { name: '库尔德武装' },
geometry: {
type: 'Point',
coordinates: EXTENDED_WAR_ZONES.kurdishLabelCenter,
},
}}
>
<Layer <Layer
id="kurdish-label-text" id="kurdish-label-text"
type="symbol" type="symbol"

View File

@@ -91,6 +91,76 @@ export const EXTENDED_WAR_ZONES = {
// 真主党区域标注点(用于显示文字) // 真主党区域标注点(用于显示文字)
hezbollahLabelCenter: [35.7, 33.7] as [number, number], hezbollahLabelCenter: [35.7, 33.7] as [number, number],
// 苏丹武装势力范围(公开支持伊朗,轮廓用伊朗色标绘制整个苏丹国家)
sudanZone: {
type: 'Feature' as const,
properties: {
name: '苏丹武装',
status: 'IRAN-ALIGNED',
color: '#EF4444',
},
geometry: {
type: 'Polygon' as const,
coordinates: [
[
[31.0, 22.0],
[33.0, 22.0],
[34.5, 22.0],
[36.8, 22.0],
[37.3, 20.8],
[38.5, 18.0],
[37.9, 17.0],
[36.5, 14.3],
[35.0, 13.5],
[34.0, 11.5],
[32.5, 12.0],
[29.5, 9.5],
[27.0, 9.0],
[24.0, 8.5],
[23.5, 10.0],
[22.5, 13.5],
[22.5, 16.0],
[24.0, 20.0],
[25.0, 22.0],
[31.0, 22.0],
],
],
},
},
sudanLabelCenter: [30.5, 15.25] as [number, number],
/** 苏丹国境线(与 sudanZone 同轮廓LineString 供线图层绘制) */
sudanBorderLine: {
type: 'Feature' as const,
properties: { name: '苏丹国境线' },
geometry: {
type: 'LineString' as const,
coordinates: [
[31.0, 22.0],
[33.0, 22.0],
[34.5, 22.0],
[36.8, 22.0],
[37.3, 20.8],
[38.5, 18.0],
[37.9, 17.0],
[36.5, 14.3],
[35.0, 13.5],
[34.0, 11.5],
[32.5, 12.0],
[29.5, 9.5],
[27.0, 9.0],
[24.0, 8.5],
[23.5, 10.0],
[22.5, 13.5],
[22.5, 16.0],
[24.0, 20.0],
[25.0, 22.0],
[31.0, 22.0],
],
},
},
// 库尔德武装势力区域标注点(叙/土/伊三区紫色带中心附近) // 库尔德武装势力区域标注点(叙/土/伊三区紫色带中心附近)
kurdishLabelCenter: [43.5, 36.3] as [number, number], kurdishLabelCenter: [43.5, 36.3] as [number, number],