diff --git a/crawler/__pycache__/db_merge.cpython-311.pyc b/crawler/__pycache__/db_merge.cpython-311.pyc index bb79ac2..f80fec5 100644 Binary files a/crawler/__pycache__/db_merge.cpython-311.pyc and b/crawler/__pycache__/db_merge.cpython-311.pyc differ diff --git a/crawler/__pycache__/indicator_smooth.cpython-311.pyc b/crawler/__pycache__/indicator_smooth.cpython-311.pyc new file mode 100644 index 0000000..80018f7 Binary files /dev/null and b/crawler/__pycache__/indicator_smooth.cpython-311.pyc differ diff --git a/crawler/db_merge.py b/crawler/db_merge.py index 2bc7958..94acfae 100644 --- a/crawler/db_merge.py +++ b/crawler/db_merge.py @@ -14,6 +14,13 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, Optional +from crawler.indicator_smooth import ( + clamp as _indicator_clamp, + smooth_retaliation as _smooth_retaliation, + smooth_wall_street as _smooth_wall_street, + wall_street_should_append as _wall_street_should_append, +) + PROJECT_ROOT = Path(__file__).resolve().parent.parent DB_PATH = os.environ.get("DB_PATH", str(PROJECT_ROOT / "server" / "data.db")) @@ -26,8 +33,7 @@ MAX_DELTA_PER_MERGE = { "civilian_ships": 20, "airport_port": 10, } -# 反击情绪 / 华尔街:合理区间,避免爬虫单条提取 0 或 100 导致指标归零或打满 -RETALIATION_SMOOTH_WEIGHT = 0.6 # 当前值权重,1 - 此值为新值权重,使更新平滑 +# 反击情绪 / 华尔街:限幅与平滑见 crawler.indicator_smooth RETALIATION_HISTORY_MAX_ROWS = 300 # 反击历史条数上限,供前端曲线与回放使用 WALL_STREET_TREND_MAX_ROWS = 200 # 趋势表保留最近条数,避免无限增长 VALUE_CLAMP_MIN, VALUE_CLAMP_MAX = 1, 99 # 0/100 视为异常,写入前夹在 [1,99] @@ -206,17 +212,13 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool: updated = True except Exception: pass - # retaliation:平滑更新,避免单条新闻 0/100 导致指标归零或打满 + # retaliation:由 indicator_smooth 计算平滑值 + 单步变化上限,避免爬虫连续更新导致剧烈波动 if "retaliation" in extracted: r = extracted["retaliation"] - raw = max(VALUE_CLAMP_MIN, min(VALUE_CLAMP_MAX, int(r.get("value", 50)))) + raw = _indicator_clamp(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)) + new_val = _smooth_retaliation(raw, current) 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)) @@ -227,13 +229,19 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool: (n_ret - RETALIATION_HISTORY_MAX_ROWS,), ) updated = True - # wall_street_trend:限幅后写入,并保留最近 N 条避免表无限增长 + # wall_street_trend:由 indicator_smooth 与上一点平滑 + 最小写入间隔,抑制密集报道导致的锯齿 if "wall_street" in extracted: w = extracted["wall_street"] 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)) + last_row = conn.execute( + "SELECT time, value FROM wall_street_trend ORDER BY time DESC LIMIT 1" + ).fetchone() + last_time = last_row[0] if last_row else None + last_val = int(last_row[1]) if last_row else None + if _wall_street_should_append(last_time, ts): + val = _smooth_wall_street(raw, last_val) + 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( diff --git a/crawler/indicator_smooth.py b/crawler/indicator_smooth.py new file mode 100644 index 0000000..6664ce6 --- /dev/null +++ b/crawler/indicator_smooth.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +""" +华尔街财团投入指数 & 反击情绪指数:从爬虫实时数据计算稳定指标,抑制单条报道导致的剧烈波动。 +供 db_merge.merge() 调用,写入同一批 DB 表,前端契约不变。 +""" +from typing import Optional, Tuple + +VALUE_CLAMP_MIN = 1 +VALUE_CLAMP_MAX = 99 + +# 华尔街:与上一点平滑,新点权重 +WALL_STREET_NEW_WEIGHT = 0.35 # raw 权重;1 - 此值 = 上一点权重,越大曲线越平滑 +# 华尔街:两次写入最小间隔(分钟),避免短时间多条报道造成密集锯齿 +WALL_STREET_MIN_INTERVAL_MINUTES = 20 + +# 反击情绪:当前值权重(1 - 此值 = 新 raw 权重) +RETALIATION_CURRENT_WEIGHT = 0.8 +# 反击情绪:单次更新相对当前值的最大变化幅度(绝对值) +RETALIATION_MAX_STEP = 5 + + +def clamp(value: int) -> int: + return max(VALUE_CLAMP_MIN, min(VALUE_CLAMP_MAX, int(value))) + + +def smooth_wall_street( + raw_value: int, + last_value: Optional[int], + *, + new_weight: float = WALL_STREET_NEW_WEIGHT, +) -> int: + """ + 华尔街投入指数:用上一点做平滑,避免单条报道 30/80 导致曲线骤变。 + 若尚无上一点,直接使用限幅后的 raw。 + """ + raw = clamp(raw_value) + if last_value is None: + return raw + w = 1.0 - new_weight + return clamp(round(w * last_value + new_weight * raw)) + + +def wall_street_should_append( + last_time_iso: Optional[str], + new_time_iso: str, + min_interval_minutes: int = WALL_STREET_MIN_INTERVAL_MINUTES, +) -> bool: + """ + 是否应追加一条华尔街趋势点。若与上一条间隔不足 min_interval_minutes 则跳过, + 减少因爬虫短时间多篇报道导致的密集锯齿。 + """ + if not last_time_iso: + return True + try: + from datetime import datetime + last = datetime.fromisoformat(last_time_iso.replace("Z", "+00:00")) + new = datetime.fromisoformat(new_time_iso.replace("Z", "+00:00")) + delta_min = (new - last).total_seconds() / 60 + return delta_min >= min_interval_minutes + except Exception: + return True + + +def smooth_retaliation( + raw_value: int, + current_value: int, + *, + current_weight: float = RETALIATION_CURRENT_WEIGHT, + max_step: int = RETALIATION_MAX_STEP, +) -> int: + """ + 反击情绪指数:先与当前值平滑,再限制单步变化幅度,避免连续多条报道导致快速漂移或抖动。 + """ + raw = clamp(raw_value) + cur = clamp(current_value) + smoothed = round(current_weight * cur + (1.0 - current_weight) * raw) + smoothed = clamp(smoothed) + # 单步变化上限 + delta = smoothed - cur + if abs(delta) > max_step: + step = max_step if delta > 0 else -max_step + smoothed = clamp(cur + step) + return smoothed diff --git a/docs/INDICATORS_WALLSTREET_RETALIATION.md b/docs/INDICATORS_WALLSTREET_RETALIATION.md new file mode 100644 index 0000000..6d17f43 --- /dev/null +++ b/docs/INDICATORS_WALLSTREET_RETALIATION.md @@ -0,0 +1,69 @@ +# 华尔街财团投入指数 & 反击情绪指数:更新逻辑与波动说明 + +## 一、数据流概览 + +``` +爬虫提取 (extractor_ai / extractor_dashscope) + → retaliation_sentiment / wall_street_value (0–100,按单篇报道) + → db_merge.merge(extracted) + → SQLite: retaliation_current, retaliation_history, wall_street_trend + → server/situationData.js getSituation() + → 前端: iranForces.retaliationSentiment(History), usForces.wallStreetInvestmentTrend + → 组件: RetaliationGauge, InvestmentTrendChart;回放: useReplaySituation 插值 +``` + +## 二、当前写入逻辑(crawler/db_merge.py) + +### 1. 华尔街财团投入指数 (wall_street_trend) + +- **表**:`wall_street_trend(id, time, value)`,前端用整表做 `wallStreetInvestmentTrend` 折线。 +- **写入**:每次爬虫产出 `extracted["wall_street"]` 时: + - 对 `value` 做限幅 `[1, 99]`,**不做任何平滑**; + - 直接 `INSERT INTO wall_street_trend (time, value) VALUES (?, ?)`; + - 表保留最近 `WALL_STREET_TREND_MAX_ROWS`(200)条。 +- **波动原因**: + - 每条报道一个点,爬虫频繁时点很密; + - 不同报道提取值差异大(如 30 / 80 / 45),曲线会剧烈锯齿; + - 无“与上一点平滑”,无时间间隔限制,易受单条 0/100 或异常值影响(虽已夹到 1–99)。 + +### 2. 反击情绪指数 (retaliation_current + retaliation_history) + +- **表**:`retaliation_current(id=1, value)` 当前值;`retaliation_history(id, time, value)` 历史曲线。 +- **写入**:每次爬虫产出 `extracted["retaliation"]` 时: + - 当前值平滑:`new_val = 0.6 * current + 0.4 * raw`,再夹到 [1, 99]; + - `retaliation_current` 更新为该 `new_val`; + - `retaliation_history` 追加一条 `(time, new_val)`; + - 历史表保留最近 `RETALIATION_HISTORY_MAX_ROWS`(300)条。 +- **波动原因**: + - 多条新闻短时间连续写入时,每次都用新的 raw 更新 current,连续多步 0.6/0.4 仍会快速漂移; + - history 每写一次就一个点,点过密且 raw 差异大时折线仍会明显抖动; + - 单步无“最大变化幅度”限制,极端 raw 仍会导致单次跳动较大。 + +## 三、爬虫侧产出形态 + +- **extractor_ai / extractor_dashscope**:仅当报道涉及对应维度时才输出: + - `retaliation_sentiment`:0–100; + - `wall_street_value`:0–100。 +- 每条报道独立一个值,多篇报道会多次调用 `merge()`,因此**波动确实主要由爬虫数据更新频率和单条取值差异导致**。 + +## 四、稳定化思路(计算模块) + +1. **华尔街** + - 与**上一点**做平滑再写入:例如 `value = alpha * last_value + (1-alpha) * raw`,再限幅; + - 可选:仅当距上一条时间超过一定间隔(如 15–30 分钟)才 INSERT,减少密集点带来的锯齿。 + +2. **反击情绪** + - 加强平滑:例如提高当前值权重(0.8 * current + 0.2 * raw); + - 单步变化上限:例如 `new_val` 相对 `current` 最多 ±N(如 5)点; + - 历史记录:可对 history 做同样限幅或间隔写入,避免曲线过密抖动。 + +实现位置:**crawler/indicator_smooth.py**,在 `db_merge.merge()` 中调用,仍写回现有表,前端与 API 契约不变。 + +### 已实现参数(可调) + +| 指标 | 参数 | 默认 | 说明 | +|------|------|------|------| +| 华尔街 | `WALL_STREET_NEW_WEIGHT` | 0.35 | 新 raw 权重,越小曲线越平滑 | +| 华尔街 | `WALL_STREET_MIN_INTERVAL_MINUTES` | 20 | 两笔趋势最小间隔(分钟),不足则本条不写入 | +| 反击情绪 | `RETALIATION_CURRENT_WEIGHT` | 0.8 | 当前值权重,越大越平滑 | +| 反击情绪 | `RETALIATION_MAX_STEP` | 5 | 单次相对当前值最大变化幅度(点) | diff --git a/src/components/WarMap.tsx b/src/components/WarMap.tsx index acc7d2b..cd496c1 100644 --- a/src/components/WarMap.tsx +++ b/src/components/WarMap.tsx @@ -318,6 +318,9 @@ export function WarMap() { if (isMobile) setLegendOpen(false) }, []) + /** 移动端打击面板收纳:默认展开,可手动收纳以腾出地图 */ + const [strikePanelOpen, setStrikePanelOpen] = useState(true) + const { usNaval, usBaseOp, usBaseDamaged, usBaseAttacked, labelsGeoJson } = useMemo(() => { const naval: GeoJSON.Feature[] = [] const op: GeoJSON.Feature[] = [] @@ -1195,8 +1198,23 @@ export function WarMap() { return (
- {/* 图例 - 全部=主视角+所有动画,实时=主视角+仅最新进展 */} -
+ {/* 打击交互面板:移动端可收纳,默认展开;桌面端始终显示 */} +
+ {/* 移动端收纳后:展开按钮 */} + + {/* 面板主体:移动端收纳时隐藏 */} +
基地 @@ -1296,6 +1314,17 @@ export function WarMap() { > 库尔德武装 + {/* 移动端收纳按钮 */} + +
{/* 右侧图例模块:可收纳悬浮按钮 */}