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,
}
# 反击情绪 / 华尔街:合理区间,避免爬虫单条提取 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:
"""单次增量上限,避免误提「累计」导致波动"""
@@ -200,38 +206,69 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
updated = True
except Exception:
pass
# retaliation
# retaliation:平滑更新,避免单条新闻 0/100 导致指标归零或打满
if "retaliation" in extracted:
r = extracted["retaliation"]
conn.execute("INSERT OR REPLACE INTO retaliation_current (id, value) VALUES (1, ?)", (r["value"],))
conn.execute("INSERT INTO retaliation_history (time, value) VALUES (?, ?)", (r["time"], r["value"]))
raw = max(VALUE_CLAMP_MIN, min(VALUE_CLAMP_MAX, int(r.get("value", 50))))
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
# wall_street_trend
# wall_street_trend:限幅后写入,并保留最近 N 条避免表无限增长
if "wall_street" in extracted:
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
# 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:
try:
for u in extracted["key_location_updates"]:
kw_raw = (u.get("name_keywords") or "").strip()
if not kw_raw:
continue
# 支持 "a|b|c" 或 "a b c" 分隔
kw = [k.strip() for k in kw_raw.replace("|", " ").split() if k.strip()]
side = u.get("side")
status = (u.get("status") or "attacked")[:20]
dmg = u.get("damage_level", 2)
if not kw or side not in ("us", "iran"):
continue
# 简化name LIKE '%kw%' 对每个关键词 OR 连接,支持中英文
attacked_at = (u.get("attacked_at") or event_time)[:25]
conditions = " OR ".join("name LIKE ?" for _ in kw)
params = [status, dmg, side] + [f"%{k}%" for k in kw]
cur = conn.execute(
f"UPDATE key_location SET status=?, damage_level=? WHERE side=? AND ({conditions})",
params,
)
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(
f"UPDATE key_location SET status=?, damage_level=? WHERE side=? AND ({conditions})",
params_no_at,
)
if cur.rowcount > 0:
updated = True
except Exception:

View File

@@ -51,6 +51,7 @@ def _call_ollama_extract(text: str, timeout: int = 15) -> Optional[Dict[str, Any
- retaliation_sentiment: 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
- 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本则报道中该方新增消耗枚数整数
原文:
@@ -133,6 +134,31 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
})
if 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 提供)
fs_delta = {}
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仅当报道涉及伊朗报复情绪时
- 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
- **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本则该方新增消耗枚数整数
原文:
@@ -133,4 +134,29 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
if 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

View File

@@ -1,11 +1,24 @@
# -*- coding: utf-8 -*-
"""
基于规则的新闻数据提取(无需 Ollama
从新闻文本中提取战损、报复情绪等数值,供 db_merge 写入
从新闻文本中提取战损、报复情绪、攻击地点与盟军打击线,供 db_merge 写入
"""
import re
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]:
@@ -251,4 +264,30 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
if 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

View File

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

View File

@@ -2,9 +2,15 @@
<html lang="zh-CN">
<head>
<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" />
<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.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">

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 (_) {}
// 生产环境可能未跑 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() {

View File

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

View File

@@ -219,9 +219,35 @@ export function WarMap() {
const situation = useReplaySituation()
const { isReplayMode, playbackTime } = usePlaybackStore()
const { usForces, iranForces, conflictEvents = [] } = situation
/** 时间衰减基准:回放模式用回放时刻,否则用数据更新时间或当前时间 */
const referenceTime =
isReplayMode ? playbackTime : situation.lastUpdated || new Date().toISOString()
/** 时间衰减基准:回放模式用回放时刻;实时模式用「当前数据中最新 struck_at/attacked_at」无事件时再用 lastUpdated/now。
* 这样爬虫推送后 lastUpdated 跳到「当前」时,窗口仍以数据中最新事件为右端,不会前移导致已有攻击路线被排除(动画消失)。 */
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 irLocs = (iranForces.keyLocations || []) as KeyLoc[]
@@ -500,9 +526,33 @@ export function WarMap() {
[hormuzTargetPoints, isReplayMode]
)
// 霍尔木兹海峡交战区 & 真主党势力范围(静态面)
const hormuzZone = EXTENDED_WAR_ZONES.hormuzCombatZone
const hezbollahZone = EXTENDED_WAR_ZONES.hezbollahZone
// 霍尔木兹海峡交战区 & 真主党势力范围(静态面);保持引用稳定,避免广播更新时触发 setData 导致轮廓线丢失
const hormuzZone = useMemo(() => EXTENDED_WAR_ZONES.hormuzCombatZone, [])
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 红脉
const { conflictEventsGreen, conflictEventsOrange, conflictEventsRed } = useMemo(() => {
@@ -546,6 +596,8 @@ export function WarMap() {
}
const initAnimation = useRef<(map: MapboxMap) => void>(null!)
const [zoneSourceKey, setZoneSourceKey] = useState(0)
const lastCheckedZoneRef = useRef<string>('')
initAnimation.current = (map: MapboxMap) => {
startRef.current = performance.now()
@@ -837,6 +889,26 @@ export function WarMap() {
return () => ro.disconnect()
}, [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) {
return (
<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="h-1.5 w-1.5 rounded-sm bg-red-500/40" />
</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="h-1.5 w-1.5 rounded-full bg-[#3B82F6]" />
</span>
@@ -1241,7 +1316,7 @@ export function WarMap() {
</Source>
{/* 跨国库尔德势力:土(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 层,参考真主党) */}
<Layer
id="kurdish-zones"
@@ -1686,6 +1761,62 @@ export function WarMap() {
/>
</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
id="iran-label"
@@ -1805,7 +1936,7 @@ export function WarMap() {
</Source>
{/* 霍尔木兹海峡交战区 - 金黄色 mesh 区域 */}
<Source id="hormuz-combat-zone" type="geojson" data={hormuzZone}>
<Source key={`zone-hormuz-${zoneSourceKey}`} id="hormuz-combat-zone" type="geojson" data={hormuzZone}>
<Layer
id="hormuz-combat-fill"
type="fill"
@@ -1826,7 +1957,7 @@ export function WarMap() {
</Source>
{/* 真主党势力范围 - 绿色半透明区域 */}
<Source id="hezbollah-zone" type="geojson" data={hezbollahZone}>
<Source key={`zone-hezbollah-${zoneSourceKey}`} id="hezbollah-zone" type="geojson" data={hezbollahZone}>
<Layer
id="hezbollah-fill"
type="fill"
@@ -1846,18 +1977,7 @@ export function WarMap() {
</Source>
{/* 霍尔木兹海峡区域标注 */}
<Source
id="hormuz-label"
type="geojson"
data={{
type: 'Feature',
properties: { name: (hormuzZone.properties as any).name },
geometry: {
type: 'Point',
coordinates: EXTENDED_WAR_ZONES.hormuzLabelCenter,
},
}}
>
<Source id="hormuz-label" type="geojson" data={hormuzLabelData}>
<Layer
id="hormuz-label-text"
type="symbol"
@@ -1876,18 +1996,7 @@ export function WarMap() {
</Source>
{/* 真主党势力范围标注 */}
<Source
id="hezbollah-label"
type="geojson"
data={{
type: 'Feature',
properties: { name: (hezbollahZone.properties as any).name },
geometry: {
type: 'Point',
coordinates: EXTENDED_WAR_ZONES.hezbollahLabelCenter,
},
}}
>
<Source id="hezbollah-label" type="geojson" data={hezbollahLabelData}>
<Layer
id="hezbollah-label-text"
type="symbol"
@@ -1906,18 +2015,7 @@ export function WarMap() {
</Source>
{/* 库尔德武装势力范围标注(参照真主党紫色区域标记) */}
<Source
id="kurdish-label"
type="geojson"
data={{
type: 'Feature',
properties: { name: '库尔德武装' },
geometry: {
type: 'Point',
coordinates: EXTENDED_WAR_ZONES.kurdishLabelCenter,
},
}}
>
<Source id="kurdish-label" type="geojson" data={kurdishLabelData}>
<Layer
id="kurdish-label-text"
type="symbol"

View File

@@ -91,6 +91,76 @@ export const EXTENDED_WAR_ZONES = {
// 真主党区域标注点(用于显示文字)
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],