fix: 优化docker 镜像
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
dist
|
||||||
|
server/data.db
|
||||||
|
.DS_Store
|
||||||
|
*.md
|
||||||
|
.cursor
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 前端 + API 一体化镜像(使用 DaoCloud 国内镜像源)
|
||||||
|
FROM docker.m.daocloud.io/library/node:20-alpine AS frontend-builder
|
||||||
|
WORKDIR /app
|
||||||
|
ARG VITE_MAPBOX_ACCESS_TOKEN
|
||||||
|
ENV VITE_MAPBOX_ACCESS_TOKEN=${VITE_MAPBOX_ACCESS_TOKEN}
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM docker.m.daocloud.io/library/node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY --from=frontend-builder /app/dist ./dist
|
||||||
|
COPY server ./server
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV API_PORT=3001
|
||||||
|
ENV DB_PATH=/data/data.db
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
COPY docker-entrypoint.sh ./
|
||||||
|
RUN chmod +x docker-entrypoint.sh
|
||||||
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
17
Dockerfile.crawler
Normal file
17
Dockerfile.crawler
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Python 爬虫服务(使用 DaoCloud 国内镜像源 + 清华 PyPI 源)
|
||||||
|
FROM docker.m.daocloud.io/library/python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY crawler/requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||||
|
COPY crawler ./
|
||||||
|
|
||||||
|
ENV DB_PATH=/data/data.db
|
||||||
|
ENV API_BASE=http://api:3001
|
||||||
|
ENV CLEANER_AI_DISABLED=1
|
||||||
|
ENV GDELT_DISABLED=1
|
||||||
|
ENV RSS_INTERVAL_SEC=60
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "realtime_conflict_service:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
19
README.md
19
README.md
@@ -78,6 +78,25 @@ npm run dev
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建并启动(需 .env 中配置 VITE_MAPBOX_ACCESS_TOKEN 以启用地图)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 访问前端:http://localhost:3001
|
||||||
|
# 数据库与爬虫共享 volume,首次启动自动 seed
|
||||||
|
```
|
||||||
|
|
||||||
|
**拉取镜像超时?** 在 Docker Desktop 配置镜像加速,见 [docs/DOCKER_MIRROR.md](docs/DOCKER_MIRROR.md)
|
||||||
|
|
||||||
|
环境变量(可选,在 .env 或 docker-compose.yml 中配置):
|
||||||
|
|
||||||
|
- `VITE_MAPBOX_ACCESS_TOKEN`:Mapbox 令牌,构建时注入
|
||||||
|
- `DB_PATH`:数据库路径(默认 /data/data.db)
|
||||||
|
- `CLEANER_AI_DISABLED=1`:爬虫默认禁用 Ollama
|
||||||
|
- `GDELT_DISABLED=1`:爬虫默认禁用 GDELT(国内易超时)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
crawler/__pycache__/extractor_rules.cpython-39.pyc
Normal file
BIN
crawler/__pycache__/extractor_rules.cpython-39.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -39,11 +39,37 @@ RSS_FEEDS = [
|
|||||||
"https://www.aljazeera.com/xml/rss/middleeast.xml",
|
"https://www.aljazeera.com/xml/rss/middleeast.xml",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 关键词过滤:至少匹配一个才会入库
|
# 关键词过滤:至少匹配一个才会入库(与地图区域对应:伊拉克/叙利亚/海湾/红海/地中海等)
|
||||||
KEYWORDS = [
|
KEYWORDS = [
|
||||||
"iran", "iranian", "tehran", "以色列", "israel",
|
# 伊朗
|
||||||
"usa", "us ", "american", "美军", "美国",
|
"iran", "iranian", "tehran", "德黑兰", "bushehr", "布什尔", "abbas", "阿巴斯",
|
||||||
"middle east", "中东", "persian gulf", "波斯湾",
|
# 以色列 / 巴勒斯坦
|
||||||
|
"israel", "以色列", "hamas", "gaza", "加沙", "hezbollah", "真主党",
|
||||||
|
# 美国
|
||||||
|
"usa", "us ", "american", "美军", "美国", "pentagon",
|
||||||
|
# 区域(地图覆盖)
|
||||||
|
"middle east", "中东", "persian gulf", "波斯湾", "gulf of oman", "阿曼湾",
|
||||||
|
"arabian sea", "阿拉伯海", "red sea", "红海", "mediterranean", "地中海",
|
||||||
|
"strait of hormuz", "霍尔木兹",
|
||||||
|
# 伊拉克 / 叙利亚
|
||||||
|
"iraq", "伊拉克", "baghdad", "巴格达", "erbil", "埃尔比勒", "basra", "巴士拉",
|
||||||
|
"syria", "叙利亚", "damascus", "大马士革", "deir", "代尔祖尔",
|
||||||
|
# 海湾国家
|
||||||
|
"saudi", "沙特", "riyadh", "利雅得", "qatar", "卡塔尔", "doha", "多哈",
|
||||||
|
"uae", "emirates", "阿联酋", "dubai", "迪拜", "abu dhabi",
|
||||||
|
"bahrain", "巴林", "kuwait", "科威特", "oman", "阿曼", "yemen", "也门",
|
||||||
|
# 约旦 / 土耳其 / 埃及 / 吉布提 / 黎巴嫩
|
||||||
|
"jordan", "约旦", "amman", "安曼",
|
||||||
|
"lebanon", "黎巴嫩",
|
||||||
|
"turkey", "土耳其", "incirlik", "因吉尔利克",
|
||||||
|
"egypt", "埃及", "cairo", "开罗", "sinai", "西奈",
|
||||||
|
"djibouti", "吉布提",
|
||||||
|
# 军事 / 基地
|
||||||
|
"al-asad", "al asad", "阿萨德", "al udeid", "乌代德", "incirlik",
|
||||||
"strike", "attack", "military", "missile", "核", "nuclear",
|
"strike", "attack", "military", "missile", "核", "nuclear",
|
||||||
"carrier", "航母", "houthi", "胡塞", "hamas",
|
"carrier", "航母", "drone", "uav", "无人机", "retaliation", "报复",
|
||||||
|
"base", "基地", "troops", "troop", "soldier", "personnel",
|
||||||
|
# 胡塞 / 武装 / 军力
|
||||||
|
"houthi", "胡塞", "houthis",
|
||||||
|
"idf", "irgc", "革命卫队", "qassem soleimani", "苏莱曼尼",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
|
|||||||
)
|
)
|
||||||
if conn.total_changes > 0:
|
if conn.total_changes > 0:
|
||||||
updated = True
|
updated = True
|
||||||
# combat_losses:增量叠加到当前值
|
# combat_losses:增量叠加到当前值,无行则先插入初始行
|
||||||
if "combat_losses_delta" in extracted:
|
if "combat_losses_delta" in extracted:
|
||||||
for side, delta in extracted["combat_losses_delta"].items():
|
for side, delta in extracted["combat_losses_delta"].items():
|
||||||
if side not in ("us", "iran"):
|
if side not in ("us", "iran"):
|
||||||
@@ -77,8 +77,9 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
|
|||||||
"SELECT personnel_killed,personnel_wounded,civilian_killed,civilian_wounded,bases_destroyed,bases_damaged,aircraft,warships,armor,vehicles FROM combat_losses WHERE side = ?",
|
"SELECT personnel_killed,personnel_wounded,civilian_killed,civilian_wounded,bases_destroyed,bases_damaged,aircraft,warships,armor,vehicles FROM combat_losses WHERE side = ?",
|
||||||
(side,),
|
(side,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
cur = {"personnel_killed": 0, "personnel_wounded": 0, "civilian_killed": 0, "civilian_wounded": 0,
|
||||||
continue
|
"bases_destroyed": 0, "bases_damaged": 0, "aircraft": 0, "warships": 0, "armor": 0, "vehicles": 0}
|
||||||
|
if row:
|
||||||
cur = {
|
cur = {
|
||||||
"personnel_killed": row[0], "personnel_wounded": row[1], "civilian_killed": row[2] or 0,
|
"personnel_killed": row[0], "personnel_wounded": row[1], "civilian_killed": row[2] or 0,
|
||||||
"civilian_wounded": row[3] or 0, "bases_destroyed": row[4], "bases_damaged": row[5],
|
"civilian_wounded": row[3] or 0, "bases_destroyed": row[4], "bases_damaged": row[5],
|
||||||
@@ -95,11 +96,18 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
|
|||||||
ar = max(0, (cur["armor"] or 0) + delta.get("armor", 0))
|
ar = max(0, (cur["armor"] or 0) + delta.get("armor", 0))
|
||||||
vh = max(0, (cur["vehicles"] or 0) + delta.get("vehicles", 0))
|
vh = max(0, (cur["vehicles"] or 0) + delta.get("vehicles", 0))
|
||||||
ts = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
ts = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
if row:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""UPDATE combat_losses SET personnel_killed=?, personnel_wounded=?, civilian_killed=?, civilian_wounded=?,
|
"""UPDATE combat_losses SET personnel_killed=?, personnel_wounded=?, civilian_killed=?, civilian_wounded=?,
|
||||||
bases_destroyed=?, bases_damaged=?, aircraft=?, warships=?, armor=?, vehicles=?, updated_at=? WHERE side=?""",
|
bases_destroyed=?, bases_damaged=?, aircraft=?, warships=?, armor=?, vehicles=?, updated_at=? WHERE side=?""",
|
||||||
(pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, ts, side),
|
(pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, ts, side),
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT OR REPLACE INTO combat_losses (side, personnel_killed, personnel_wounded, civilian_killed, civilian_wounded,
|
||||||
|
bases_destroyed, bases_damaged, aircraft, warships, armor, vehicles, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(side, pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, ts),
|
||||||
|
)
|
||||||
if conn.total_changes > 0:
|
if conn.total_changes > 0:
|
||||||
updated = True
|
updated = True
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -115,6 +123,30 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
|
|||||||
w = extracted["wall_street"]
|
w = extracted["wall_street"]
|
||||||
conn.execute("INSERT INTO wall_street_trend (time, value) VALUES (?, ?)", (w["time"], w["value"]))
|
conn.execute("INSERT INTO wall_street_trend (time, value) VALUES (?, ?)", (w["time"], w["value"]))
|
||||||
updated = True
|
updated = True
|
||||||
|
# key_location:更新受袭基地 status/damage_level
|
||||||
|
if "key_location_updates" in extracted:
|
||||||
|
try:
|
||||||
|
for u in extracted["key_location_updates"]:
|
||||||
|
kw = (u.get("name_keywords") or "").replace("|", " ").split()
|
||||||
|
side = u.get("side")
|
||||||
|
status = u.get("status", "attacked")[:20]
|
||||||
|
dmg = u.get("damage_level", 2)
|
||||||
|
if not kw or side not in ("us", "iran"):
|
||||||
|
continue
|
||||||
|
conditions = " OR ".join(
|
||||||
|
"(LOWER(name) LIKE ? OR name LIKE ?)" for _ in kw
|
||||||
|
)
|
||||||
|
params = [status, dmg, side]
|
||||||
|
for k in kw:
|
||||||
|
params.extend([f"%{k}%", f"%{k}%"])
|
||||||
|
cur = conn.execute(
|
||||||
|
f"UPDATE key_location SET status=?, damage_level=? WHERE side=? AND ({conditions})",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
updated = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if updated:
|
if updated:
|
||||||
conn.execute("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)", (datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),))
|
conn.execute("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)", (datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -27,11 +27,16 @@ def _call_ollama_extract(text: str, timeout: int = 10) -> Optional[Dict[str, Any
|
|||||||
- summary: 1-2句中文事实,≤80字
|
- summary: 1-2句中文事实,≤80字
|
||||||
- category: deployment|alert|intel|diplomatic|other
|
- category: deployment|alert|intel|diplomatic|other
|
||||||
- severity: low|medium|high|critical
|
- severity: low|medium|high|critical
|
||||||
- us_personnel_killed, iran_personnel_killed 等:仅当新闻明确提及具体数字时填写
|
- 战损(仅当新闻明确提及数字时填写,格式 us_XXX / iran_XXX):
|
||||||
|
us_personnel_killed, iran_personnel_killed, us_personnel_wounded, iran_personnel_wounded,
|
||||||
|
us_civilian_killed, iran_civilian_killed, us_civilian_wounded, iran_civilian_wounded,
|
||||||
|
us_bases_destroyed, iran_bases_destroyed, us_bases_damaged, iran_bases_damaged,
|
||||||
|
us_aircraft, iran_aircraft, us_warships, iran_warships, us_armor, iran_armor, us_vehicles, iran_vehicles
|
||||||
- retaliation_sentiment: 0-100,仅当新闻涉及伊朗报复情绪时
|
- retaliation_sentiment: 0-100,仅当新闻涉及伊朗报复情绪时
|
||||||
- wall_street_value: 0-100,仅当新闻涉及美股/市场反应时
|
- wall_street_value: 0-100,仅当新闻涉及美股/市场反应时
|
||||||
|
- key_location_updates: 当新闻提及具体基地/地点遭袭时,数组项 { "name_keywords": "asad|阿萨德|assad", "side": "us", "status": "attacked", "damage_level": 1-3 }
|
||||||
|
|
||||||
原文:{str(text)[:500]}
|
原文:{str(text)[:800]}
|
||||||
|
|
||||||
直接输出 JSON,不要解释:"""
|
直接输出 JSON,不要解释:"""
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
@@ -97,4 +102,17 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
|
|||||||
v = parsed["wall_street_value"]
|
v = parsed["wall_street_value"]
|
||||||
if isinstance(v, (int, float)) and 0 <= v <= 100:
|
if isinstance(v, (int, float)) and 0 <= v <= 100:
|
||||||
out["wall_street"] = {"time": ts, "value": int(v)}
|
out["wall_street"] = {"time": ts, "value": int(v)}
|
||||||
|
# key_location_updates:受袭基地
|
||||||
|
if "key_location_updates" in parsed and isinstance(parsed["key_location_updates"], list):
|
||||||
|
valid = []
|
||||||
|
for u in parsed["key_location_updates"]:
|
||||||
|
if isinstance(u, dict) and u.get("name_keywords") and u.get("side") in ("us", "iran"):
|
||||||
|
valid.append({
|
||||||
|
"name_keywords": str(u["name_keywords"]),
|
||||||
|
"side": u["side"],
|
||||||
|
"status": str(u.get("status", "attacked"))[:20],
|
||||||
|
"damage_level": min(3, max(1, int(u["damage_level"]))) if isinstance(u.get("damage_level"), (int, float)) else 2,
|
||||||
|
})
|
||||||
|
if valid:
|
||||||
|
out["key_location_updates"] = valid
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -24,18 +24,64 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A
|
|||||||
t = (text or "").lower()
|
t = (text or "").lower()
|
||||||
|
|
||||||
loss_us, loss_ir = {}, {}
|
loss_us, loss_ir = {}, {}
|
||||||
v = _first_int(t, r"(?:us|american|u\.?s\.?)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|killed|dead)")
|
|
||||||
|
# 美军人员伤亡
|
||||||
|
v = _first_int(t, r"(?:us|american|u\.?s\.?)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|military)[\s\w]*(?:killed|dead)")
|
||||||
if v is not None:
|
if v is not None:
|
||||||
loss_us["personnel_killed"] = v
|
loss_us["personnel_killed"] = v
|
||||||
v = _first_int(t, r"(\d+)[\s\w]*(?:us|american)[\s\w]*(?:troop|soldier|killed|dead)")
|
v = _first_int(t, r"(\d+)[\s\w]*(?:us|american)[\s\w]*(?:troop|soldier|military)[\s\w]*(?:killed|dead)")
|
||||||
if v is not None:
|
if v is not None:
|
||||||
loss_us["personnel_killed"] = v
|
loss_us["personnel_killed"] = v
|
||||||
v = _first_int(t, r"(?:iran|iranian)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|killed|dead)")
|
v = _first_int(t, r"(?:us|american)[\s\w]*(\d+)[\s\w]*(?:wounded|injured)")
|
||||||
|
if v is not None:
|
||||||
|
loss_us["personnel_wounded"] = v
|
||||||
|
|
||||||
|
# 伊朗人员伤亡
|
||||||
|
v = _first_int(t, r"(?:iran|iranian)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|guard|killed|dead)")
|
||||||
if v is not None:
|
if v is not None:
|
||||||
loss_ir["personnel_killed"] = v
|
loss_ir["personnel_killed"] = v
|
||||||
v = _first_int(t, r"(\d+)[\s\w]*(?:iranian|iran)[\s\w]*(?:troop|soldier|killed|dead)")
|
v = _first_int(t, r"(\d+)[\s\w]*(?:iranian|iran)[\s\w]*(?:troop|soldier|guard|killed|dead)")
|
||||||
if v is not None:
|
if v is not None:
|
||||||
loss_ir["personnel_killed"] = v
|
loss_ir["personnel_killed"] = v
|
||||||
|
v = _first_int(t, r"(?:iran|iranian)[\s\w]*(\d+)[\s\w]*(?:wounded|injured)")
|
||||||
|
if v is not None:
|
||||||
|
loss_ir["personnel_wounded"] = v
|
||||||
|
|
||||||
|
# 平民伤亡(多不区分阵营,计入双方或仅 us 因多为美国基地周边)
|
||||||
|
v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:killed|dead)")
|
||||||
|
if v is not None:
|
||||||
|
loss_us["civilian_killed"] = v
|
||||||
|
v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:wounded|injured)")
|
||||||
|
if v is not None:
|
||||||
|
loss_us["civilian_wounded"] = v
|
||||||
|
|
||||||
|
# 基地损毁(美方基地居多)
|
||||||
|
v = _first_int(t, r"(\d+)[\s\w]*(?:base)[\s\w]*(?:destroyed|leveled)")
|
||||||
|
if v is not None:
|
||||||
|
loss_us["bases_destroyed"] = v
|
||||||
|
v = _first_int(t, r"(\d+)[\s\w]*(?:base)[\s\w]*(?:damaged|hit|struck)")
|
||||||
|
if v is not None:
|
||||||
|
loss_us["bases_damaged"] = v
|
||||||
|
if "base" in t and ("destroy" in t or "level" in t) and not loss_us.get("bases_destroyed"):
|
||||||
|
loss_us["bases_destroyed"] = 1
|
||||||
|
if "base" in t and ("damage" in t or "hit" in t or "struck" in t or "strike" in t) and not loss_us.get("bases_damaged"):
|
||||||
|
loss_us["bases_damaged"] = 1
|
||||||
|
|
||||||
|
# 战机 / 舰船(根据上下文判断阵营)
|
||||||
|
v = _first_int(t, r"(\d+)[\s\w]*(?:aircraft|plane|jet|fighter|f-?16|f-?35|f-?18)[\s\w]*(?:down|destroyed|lost|shot)")
|
||||||
|
if v is not None:
|
||||||
|
if "us" in t or "american" in t or "u.s" in t:
|
||||||
|
loss_us["aircraft"] = v
|
||||||
|
elif "iran" in t:
|
||||||
|
loss_ir["aircraft"] = v
|
||||||
|
else:
|
||||||
|
loss_us["aircraft"] = v
|
||||||
|
v = _first_int(t, r"(\d+)[\s\w]*(?:ship|destroyer|warship|vessel)[\s\w]*(?:hit|damaged|sunk)")
|
||||||
|
if v is not None:
|
||||||
|
if "iran" in t:
|
||||||
|
loss_ir["warships"] = v
|
||||||
|
else:
|
||||||
|
loss_us["warships"] = v
|
||||||
|
|
||||||
if loss_us:
|
if loss_us:
|
||||||
out.setdefault("combat_losses_delta", {})["us"] = loss_us
|
out.setdefault("combat_losses_delta", {})["us"] = loss_us
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
|
||||||
|
|
||||||
logging.getLogger("apscheduler.scheduler").setLevel(logging.ERROR)
|
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||||
app = FastAPI(title="GDELT Conflict Service")
|
app = FastAPI(title="GDELT Conflict Service")
|
||||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"])
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"])
|
||||||
|
|
||||||
@@ -263,8 +263,9 @@ def _extract_and_merge_panel_data(items: list) -> None:
|
|||||||
from extractor_ai import extract_from_news
|
from extractor_ai import extract_from_news
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
merged_any = False
|
merged_any = False
|
||||||
# 只对前几条有足够文本的新闻做提取,避免 Ollama 调用过多
|
# 规则模式可多处理几条(无 Ollama);AI 模式限制 5 条避免调用过多
|
||||||
for it in items[:5]:
|
limit = 10 if os.environ.get("CLEANER_AI_DISABLED", "0") == "1" else 5
|
||||||
|
for it in items[:limit]:
|
||||||
text = (it.get("title", "") or "") + " " + (it.get("summary", "") or "")
|
text = (it.get("title", "") or "") + " " + (it.get("summary", "") or "")
|
||||||
if len(text.strip()) < 20:
|
if len(text.strip()) < 20:
|
||||||
continue
|
continue
|
||||||
@@ -292,12 +293,22 @@ def _extract_and_merge_panel_data(items: list) -> None:
|
|||||||
|
|
||||||
|
|
||||||
# ==========================
|
# ==========================
|
||||||
# 定时任务(RSS 更频繁,优先保证事件脉络实时)
|
# 定时任务(asyncio 后台任务,避免 APScheduler executor 关闭竞态)
|
||||||
# ==========================
|
# ==========================
|
||||||
scheduler = BackgroundScheduler()
|
_bg_task: Optional[asyncio.Task] = None
|
||||||
scheduler.add_job(fetch_news, "interval", seconds=RSS_INTERVAL_SEC, max_instances=2, coalesce=True)
|
|
||||||
scheduler.add_job(fetch_gdelt_events, "interval", seconds=FETCH_INTERVAL_SEC, max_instances=2, coalesce=True)
|
|
||||||
scheduler.start()
|
async def _periodic_fetch() -> None:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(None, fetch_news)
|
||||||
|
await loop.run_in_executor(None, fetch_gdelt_events)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [warn] 定时抓取: {e}")
|
||||||
|
await asyncio.sleep(min(RSS_INTERVAL_SEC, FETCH_INTERVAL_SEC))
|
||||||
|
|
||||||
|
|
||||||
# ==========================
|
# ==========================
|
||||||
@@ -356,17 +367,22 @@ def _get_conflict_stats() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup():
|
async def startup():
|
||||||
# 新闻优先启动,确保事件脉络有数据
|
global _bg_task
|
||||||
fetch_news()
|
loop = asyncio.get_event_loop()
|
||||||
fetch_gdelt_events()
|
await loop.run_in_executor(None, fetch_news)
|
||||||
|
await loop.run_in_executor(None, fetch_gdelt_events)
|
||||||
|
_bg_task = asyncio.create_task(_periodic_fetch())
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
def shutdown():
|
async def shutdown():
|
||||||
|
global _bg_task
|
||||||
|
if _bg_task and not _bg_task.done():
|
||||||
|
_bg_task.cancel()
|
||||||
try:
|
try:
|
||||||
scheduler.shutdown(wait=False)
|
await _bg_task
|
||||||
except Exception:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,4 @@ requests>=2.31.0
|
|||||||
feedparser>=6.0.0
|
feedparser>=6.0.0
|
||||||
fastapi>=0.109.0
|
fastapi>=0.109.0
|
||||||
uvicorn>=0.27.0
|
uvicorn>=0.27.0
|
||||||
apscheduler>=3.10.0
|
|
||||||
deep-translator>=1.11.0
|
deep-translator>=1.11.0
|
||||||
|
|||||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
- VITE_MAPBOX_ACCESS_TOKEN=${VITE_MAPBOX_ACCESS_TOKEN:-}
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
environment:
|
||||||
|
- DB_PATH=/data/data.db
|
||||||
|
- API_PORT=3001
|
||||||
|
volumes:
|
||||||
|
- app-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
crawler:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.crawler
|
||||||
|
environment:
|
||||||
|
- DB_PATH=/data/data.db
|
||||||
|
- API_BASE=http://api:3001
|
||||||
|
- CLEANER_AI_DISABLED=1
|
||||||
|
- GDELT_DISABLED=1
|
||||||
|
- RSS_INTERVAL_SEC=60
|
||||||
|
volumes:
|
||||||
|
- app-data:/data
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app-data:
|
||||||
8
docker-entrypoint.sh
Normal file
8
docker-entrypoint.sh
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
export DB_PATH="${DB_PATH:-/data/data.db}"
|
||||||
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
|
echo "==> Seeding database..."
|
||||||
|
node server/seed.js
|
||||||
|
fi
|
||||||
|
exec node server/index.js
|
||||||
30
docs/DOCKER_MIRROR.md
Normal file
30
docs/DOCKER_MIRROR.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Docker 拉取超时 / 配置镜像加速
|
||||||
|
|
||||||
|
国内环境从 Docker Hub 拉取镜像常超时,需在 Docker 中配置镜像加速。
|
||||||
|
|
||||||
|
## Docker Desktop(macOS / Windows)
|
||||||
|
|
||||||
|
1. 打开 **Docker Desktop**
|
||||||
|
2. **Settings** → **Docker Engine**
|
||||||
|
3. 在 JSON 中增加 `registry-mirrors`(若已有其他配置,只需合并进该字段):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registry-mirrors": [
|
||||||
|
"https://docker.m.daocloud.io",
|
||||||
|
"https://docker.1ms.run"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 点击 **Apply & Restart**
|
||||||
|
5. 重新执行:`docker compose up -d --build`
|
||||||
|
|
||||||
|
## 备选镜像源
|
||||||
|
|
||||||
|
可替换或补充到 `registry-mirrors` 中:
|
||||||
|
|
||||||
|
- `https://docker.m.daocloud.io`(DaoCloud)
|
||||||
|
- `https://docker.1ms.run`
|
||||||
|
- `https://docker.rainbond.cc`(好雨科技)
|
||||||
|
- 阿里云 / 腾讯云等:在对应云控制台的「容器镜像服务」中获取个人专属加速地址
|
||||||
Binary file not shown.
Binary file not shown.
17
server/db.js
17
server/db.js
@@ -1,7 +1,7 @@
|
|||||||
const Database = require('better-sqlite3')
|
const Database = require('better-sqlite3')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
const dbPath = path.join(__dirname, 'data.db')
|
const dbPath = process.env.DB_PATH || path.join(__dirname, 'data.db')
|
||||||
const db = new Database(dbPath)
|
const db = new Database(dbPath)
|
||||||
|
|
||||||
// 启用外键
|
// 启用外键
|
||||||
@@ -147,4 +147,19 @@ addUpdatedAt('force_asset')
|
|||||||
addUpdatedAt('key_location')
|
addUpdatedAt('key_location')
|
||||||
addUpdatedAt('retaliation_current')
|
addUpdatedAt('retaliation_current')
|
||||||
|
|
||||||
|
// 来访统计:visits 用于在看(近期活跃 IP),visitor_count 用于累积人次(每次接入 +1)
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS visits (
|
||||||
|
ip TEXT PRIMARY KEY,
|
||||||
|
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS visitor_count (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO visitor_count (id, total) VALUES (1, 0);
|
||||||
|
`)
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
module.exports = db
|
module.exports = db
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
const http = require('http')
|
const http = require('http')
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
const cors = require('cors')
|
const cors = require('cors')
|
||||||
const { WebSocketServer } = require('ws')
|
const { WebSocketServer } = require('ws')
|
||||||
@@ -8,6 +10,7 @@ const { getSituation } = require('./situationData')
|
|||||||
const app = express()
|
const app = express()
|
||||||
const PORT = process.env.API_PORT || 3001
|
const PORT = process.env.API_PORT || 3001
|
||||||
|
|
||||||
|
app.set('trust proxy', 1)
|
||||||
app.use(cors())
|
app.use(cors())
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
app.use('/api', routes)
|
app.use('/api', routes)
|
||||||
@@ -17,6 +20,17 @@ app.post('/api/crawler/notify', (_, res) => {
|
|||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 生产环境:提供前端静态文件
|
||||||
|
const distPath = path.join(__dirname, '..', 'dist')
|
||||||
|
if (fs.existsSync(distPath)) {
|
||||||
|
app.use(express.static(distPath))
|
||||||
|
app.get('*', (req, res, next) => {
|
||||||
|
if (!req.path.startsWith('/api') && req.path !== '/ws') {
|
||||||
|
res.sendFile(path.join(distPath, 'index.html'))
|
||||||
|
} else next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const server = http.createServer(app)
|
const server = http.createServer(app)
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server, path: '/ws' })
|
const wss = new WebSocketServer({ server, path: '/ws' })
|
||||||
|
|||||||
@@ -62,6 +62,46 @@ router.get('/situation', (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 来访统计:记录 IP,返回在看/累积
|
||||||
|
function getClientIp(req) {
|
||||||
|
const forwarded = req.headers['x-forwarded-for']
|
||||||
|
if (forwarded) return forwarded.split(',')[0].trim()
|
||||||
|
return req.ip || req.socket?.remoteAddress || 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/visit', (req, res) => {
|
||||||
|
try {
|
||||||
|
const ip = getClientIp(req)
|
||||||
|
db.prepare(
|
||||||
|
"INSERT OR REPLACE INTO visits (ip, last_seen) VALUES (?, datetime('now'))"
|
||||||
|
).run(ip)
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO visitor_count (id, total) VALUES (1, 1) ON CONFLICT(id) DO UPDATE SET total = total + 1'
|
||||||
|
).run()
|
||||||
|
const viewers = db.prepare(
|
||||||
|
"SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')"
|
||||||
|
).get().n
|
||||||
|
const cumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0
|
||||||
|
res.json({ viewers, cumulative })
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
res.status(500).json({ viewers: 0, cumulative: 0 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/stats', (req, res) => {
|
||||||
|
try {
|
||||||
|
const viewers = db.prepare(
|
||||||
|
"SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')"
|
||||||
|
).get().n
|
||||||
|
const cumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0
|
||||||
|
res.json({ viewers, cumulative })
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
res.status(500).json({ viewers: 0, cumulative: 0 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/events', (req, res) => {
|
router.get('/events', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const s = getSituation()
|
const s = getSituation()
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { StatCard } from './StatCard'
|
import { StatCard } from './StatCard'
|
||||||
import { useSituationStore } from '@/store/situationStore'
|
import { useSituationStore } from '@/store/situationStore'
|
||||||
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
||||||
import { usePlaybackStore } from '@/store/playbackStore'
|
import { usePlaybackStore } from '@/store/playbackStore'
|
||||||
import { Wifi, WifiOff, Clock, Database } from 'lucide-react'
|
import { Wifi, WifiOff, Clock, Share2, Heart, Eye } from 'lucide-react'
|
||||||
|
|
||||||
|
const STORAGE_LIKES = 'us-iran-dashboard-likes'
|
||||||
|
|
||||||
|
function getStoredLikes(): number {
|
||||||
|
try {
|
||||||
|
return parseInt(localStorage.getItem(STORAGE_LIKES) ?? '0', 10)
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function HeaderPanel() {
|
export function HeaderPanel() {
|
||||||
const situation = useReplaySituation()
|
const situation = useReplaySituation()
|
||||||
@@ -12,12 +21,64 @@ export function HeaderPanel() {
|
|||||||
const isReplayMode = usePlaybackStore((s) => s.isReplayMode)
|
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())
|
||||||
|
const [likes, setLikes] = useState(getStoredLikes)
|
||||||
|
const [liked, setLiked] = useState(false)
|
||||||
|
const [viewers, setViewers] = useState(0)
|
||||||
|
const [cumulative, setCumulative] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => setNow(new Date()), 1000)
|
const timer = setInterval(() => setNow(new Date()), 1000)
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/visit', { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.viewers != null) setViewers(data.viewers)
|
||||||
|
if (data.cumulative != null) setCumulative(data.cumulative)
|
||||||
|
} catch {
|
||||||
|
setViewers((v) => (v > 0 ? v : 0))
|
||||||
|
setCumulative((c) => (c > 0 ? c : 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats()
|
||||||
|
const t = setInterval(fetchStats, 30000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleShare = async () => {
|
||||||
|
const url = window.location.href
|
||||||
|
const title = '美伊军事态势显示'
|
||||||
|
if (typeof navigator.share === 'function') {
|
||||||
|
try {
|
||||||
|
await navigator.share({ title, url })
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name !== 'AbortError') {
|
||||||
|
await copyToClipboard(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await copyToClipboard(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
return navigator.clipboard?.writeText(text) ?? Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLike = () => {
|
||||||
|
if (liked) return
|
||||||
|
setLiked(true)
|
||||||
|
const next = likes + 1
|
||||||
|
setLikes(next)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_LIKES, String(next))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const formatDateTime = (d: Date) =>
|
const formatDateTime = (d: Date) =>
|
||||||
d.toLocaleString('zh-CN', {
|
d.toLocaleString('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -59,14 +120,33 @@ export function HeaderPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-3">
|
<div className="flex shrink-0 items-center gap-4">
|
||||||
<Link
|
<div className="flex items-center gap-2 text-military-text-secondary">
|
||||||
to="/db"
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-[10px]">在看 <b className="text-military-accent tabular-nums">{viewers}</b></span>
|
||||||
|
<span className="text-[10px] opacity-70">|</span>
|
||||||
|
<span className="text-[10px]">累积 <b className="tabular-nums">{cumulative}</b></span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleShare}
|
||||||
className="flex items-center gap-1 rounded border border-military-border px-2 py-1 text-[10px] text-military-text-secondary hover:bg-military-border/30 hover:text-cyan-400"
|
className="flex items-center gap-1 rounded border border-military-border px-2 py-1 text-[10px] text-military-text-secondary hover:bg-military-border/30 hover:text-cyan-400"
|
||||||
>
|
>
|
||||||
<Database className="h-3 w-3" />
|
<Share2 className="h-3 w-3" />
|
||||||
数据库
|
分享
|
||||||
</Link>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLike}
|
||||||
|
className={`flex items-center gap-1 rounded border px-2 py-1 text-[10px] transition-colors ${
|
||||||
|
liked
|
||||||
|
? 'border-red-500/50 bg-red-500/20 text-red-400'
|
||||||
|
: 'border-military-border text-military-text-secondary hover:bg-military-border/30 hover:text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Heart className={`h-3 w-3 ${liked ? 'fill-current' : ''}`} />
|
||||||
|
点赞 {likes > 0 && <span className="tabular-nums">{likes}</span>}
|
||||||
|
</button>
|
||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
<>
|
<>
|
||||||
<Wifi className="h-3.5 w-3.5 text-green-500" />
|
<Wifi className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
|||||||
@@ -286,6 +286,8 @@ export function WarMap() {
|
|||||||
|
|
||||||
const tick = (t: number) => {
|
const tick = (t: number) => {
|
||||||
const elapsed = t - startRef.current
|
const elapsed = t - startRef.current
|
||||||
|
const zoom = map.getZoom()
|
||||||
|
const zoomScale = Math.max(0.5, zoom / 4.2) // 随镜头缩放:放大变大、缩小变小(4.2 为默认 zoom)
|
||||||
try {
|
try {
|
||||||
// 光点从起点飞向目标的循环动画
|
// 光点从起点飞向目标的循环动画
|
||||||
const src = map.getSource('attack-dots') as { setData: (d: GeoJSON.FeatureCollection) => void } | undefined
|
const src = map.getSource('attack-dots') as { setData: (d: GeoJSON.FeatureCollection) => void } | undefined
|
||||||
@@ -307,11 +309,11 @@ export function WarMap() {
|
|||||||
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.003)
|
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.003)
|
||||||
map.setPaintProperty('points-damaged', 'circle-opacity', blink)
|
map.setPaintProperty('points-damaged', 'circle-opacity', blink)
|
||||||
}
|
}
|
||||||
// attacked: 红色脉冲 2s 循环, 扩散半径 0→40px, opacity 1→0 (map.md)
|
// attacked: 红色脉冲 2s 循环, 半径随 zoom 缩放
|
||||||
if (map.getLayer('points-attacked-pulse')) {
|
if (map.getLayer('points-attacked-pulse')) {
|
||||||
const cycle = 2000
|
const cycle = 2000
|
||||||
const phase = (elapsed % cycle) / cycle
|
const phase = (elapsed % cycle) / cycle
|
||||||
const r = 40 * phase
|
const r = 40 * phase * zoomScale
|
||||||
const opacity = 1 - phase
|
const opacity = 1 - phase
|
||||||
map.setPaintProperty('points-attacked-pulse', 'circle-radius', r)
|
map.setPaintProperty('points-attacked-pulse', 'circle-radius', r)
|
||||||
map.setPaintProperty('points-attacked-pulse', 'circle-opacity', opacity)
|
map.setPaintProperty('points-attacked-pulse', 'circle-opacity', opacity)
|
||||||
@@ -376,11 +378,11 @@ export function WarMap() {
|
|||||||
)
|
)
|
||||||
israelSrc.setData({ type: 'FeatureCollection', features })
|
israelSrc.setData({ type: 'FeatureCollection', features })
|
||||||
}
|
}
|
||||||
// 伊朗被打击目标:蓝色脉冲 (2s 周期)
|
// 伊朗被打击目标:蓝色脉冲 (2s 周期), 半径随 zoom 缩放
|
||||||
if (map.getLayer('allied-strike-targets-pulse')) {
|
if (map.getLayer('allied-strike-targets-pulse')) {
|
||||||
const cycle = 2000
|
const cycle = 2000
|
||||||
const phase = (elapsed % cycle) / cycle
|
const phase = (elapsed % cycle) / cycle
|
||||||
const r = 35 * phase
|
const r = 35 * phase * zoomScale
|
||||||
const opacity = Math.max(0, 1 - phase * 1.2)
|
const opacity = Math.max(0, 1 - phase * 1.2)
|
||||||
map.setPaintProperty('allied-strike-targets-pulse', 'circle-radius', r)
|
map.setPaintProperty('allied-strike-targets-pulse', 'circle-radius', r)
|
||||||
map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity)
|
map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity)
|
||||||
@@ -390,11 +392,11 @@ export function WarMap() {
|
|||||||
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004)
|
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004)
|
||||||
map.setPaintProperty('gdelt-events-orange', 'circle-opacity', blink)
|
map.setPaintProperty('gdelt-events-orange', 'circle-opacity', blink)
|
||||||
}
|
}
|
||||||
// GDELT 红色 (7–10):脉冲扩散
|
// GDELT 红色 (7–10):脉冲扩散, 半径随 zoom 缩放
|
||||||
if (map.getLayer('gdelt-events-red-pulse')) {
|
if (map.getLayer('gdelt-events-red-pulse')) {
|
||||||
const cycle = 2200
|
const cycle = 2200
|
||||||
const phase = (elapsed % cycle) / cycle
|
const phase = (elapsed % cycle) / cycle
|
||||||
const r = 30 * phase
|
const r = 30 * phase * zoomScale
|
||||||
const opacity = Math.max(0, 1 - phase * 1.1)
|
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-radius', r)
|
||||||
map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)
|
map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)
|
||||||
|
|||||||
Reference in New Issue
Block a user