From 807ccba89cb04793fc3608fa5872aacb5b24ae71 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 4 Mar 2026 03:11:21 +0800 Subject: [PATCH] feat: add historical accuracy chart and modal to web UI --- web/app.py | 32 ++++++++ web/static/app.js | 169 ++++++++++++++++++++++++++++++++++++++++++ web/static/index.html | 17 +++++ web/static/style.css | 107 ++++++++++++++++++++++++++ 4 files changed, 325 insertions(+) diff --git a/web/app.py b/web/app.py index e6abad17..fc0360e5 100644 --- a/web/app.py +++ b/web/app.py @@ -464,6 +464,38 @@ async def city_detail(name: str): return _analyze(name) +@app.get("/api/history/{name}") +async def city_history(name: str): + """Return historical accuracy data (DEB, mu, actuals) for a city.""" + name = name.lower().strip().replace("-", " ") + name = ALIASES.get(name, name) + + from src.analysis.deb_algorithm import load_history + import os + + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + history_file = os.path.join(project_root, "data", "daily_records.json") + data = load_history(history_file) + + if name not in data: + return {"history": []} + + city_data = data[name] + out = [] + for d, rec in sorted(city_data.items()): + act = rec.get("actual_high") + deb = rec.get("deb_prediction") + mu = rec.get("mu") + + # Only return items where we have at least an actual or a prediction + out.append({ + "date": d, + "actual": float(act) if act is not None else None, + "deb": float(deb) if deb is not None else None, + "mu": float(mu) if mu is not None else None, + }) + return {"history": out} + # ────────────────────────────────────────────────────────── # Entrypoint # ────────────────────────────────────────────────────────── diff --git a/web/static/app.js b/web/static/app.js index 87c0d4c9..26bd9504 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -820,6 +820,164 @@ async function loadAllCitiesProgressively(cities) { } } +// ────────────────────────────────────────────────────────── +// History Chart Logic +// ────────────────────────────────────────────────────────── +let historyChartInst = null; + +async function openHistoryModal() { + if (!selectedCity) return; + + const modal = document.getElementById("historyModal"); + const title = document.getElementById("historyModalTitle"); + const statsDiv = document.getElementById("historyStats"); + + modal.classList.remove("hidden"); + title.textContent = `历史准确率对账 - ${selectedCity.toUpperCase()}`; + statsDiv.innerHTML = + '正在获取底层数据库...'; + + try { + const res = await fetch(`/api/history/${encodeURIComponent(selectedCity)}`); + const json = await res.json(); + const data = json.history || []; + + if (data.length === 0) { + statsDiv.innerHTML = + '暂无该城市历史数据'; + if (historyChartInst) historyChartInst.destroy(); + return; + } + + // Compute stats + let hits = 0; + let debErrors = []; + let muErrors = []; + + const dates = []; + const actuals = []; + const debs = []; + const mus = []; + + data.forEach((row) => { + dates.push(row.date); + actuals.push(row.actual); + debs.push(row.deb); + mus.push(row.mu); + + if (row.actual != null && row.deb != null) { + debErrors.push(Math.abs(row.actual - row.deb)); + if (Math.round(row.actual) === Math.round(row.deb)) { + hits++; + } + } + if (row.actual != null && row.mu != null) { + muErrors.push(Math.abs(row.actual - row.mu)); + } + }); + + const hitRate = debErrors.length + ? ((hits / debErrors.length) * 100).toFixed(0) + : 0; + const debMae = debErrors.length + ? (debErrors.reduce((a, b) => a + b, 0) / debErrors.length).toFixed(1) + : "-"; + const muMae = muErrors.length + ? (muErrors.reduce((a, b) => a + b, 0) / muErrors.length).toFixed(1) + : "-"; + + statsDiv.innerHTML = ` +
DEB 结算胜率 (WU)${hitRate}%
+
DEB MAE${debMae}°
+
μ (概率) MAE${muMae}°
+
有效样本数${data.length}天
+ `; + + if (historyChartInst) historyChartInst.destroy(); + const ctx = document.getElementById("historyChart").getContext("2d"); + + historyChartInst = new Chart(ctx, { + type: "line", + data: { + labels: dates, + datasets: [ + { + label: "实测最高温", + data: actuals, + borderColor: "#f87171", // red + backgroundColor: "rgba(248, 113, 113, 0.1)", + borderWidth: 2, + tension: 0.2, + pointRadius: 4, + pointBackgroundColor: "#f87171", + pointBorderColor: "#fff", + zIndex: 10, + }, + { + label: "DEB 融合", + data: debs, + borderColor: "#34d399", // emerald + backgroundColor: "transparent", + borderWidth: 2, + borderDash: [5, 4], + tension: 0.2, + pointRadius: 3, + }, + { + label: "μ (概率锚定)", + data: mus, + borderColor: "#a78bfa", // purple + backgroundColor: "transparent", + borderWidth: 2, + borderDash: [2, 2], + tension: 0.2, + pointRadius: 3, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + plugins: { + legend: { + labels: { color: "#94a3b8", font: { family: "Inter", size: 12 } }, + }, + tooltip: { + backgroundColor: "rgba(15, 23, 42, 0.9)", + borderColor: "rgba(255, 255, 255, 0.1)", + borderWidth: 1, + titleFont: { family: "Inter" }, + bodyFont: { family: "Inter" }, + callbacks: { + label: (ctx) => + `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`, + }, + }, + }, + scales: { + x: { + grid: { color: "rgba(255,255,255,0.04)" }, + ticks: { color: "#64748b", font: { family: "Inter", size: 10 } }, + }, + y: { + grid: { color: "rgba(255,255,255,0.04)" }, + ticks: { color: "#64748b", font: { family: "Inter", size: 10 } }, + }, + }, + }, + }); + } catch (e) { + console.error("Failed to load history", e); + statsDiv.innerHTML = + '获取历史信息失败'; + } +} + +function closeHistoryModal() { + document.getElementById("historyModal").classList.add("hidden"); +} + // ────────────────────────────────────────────────────────── // Init // ────────────────────────────────────────────────────────── @@ -834,6 +992,17 @@ document.addEventListener("DOMContentLoaded", async () => { if (e.key === "Escape") closePanel(); }); + // History Modal Events + document + .getElementById("btnShowHistory") + .addEventListener("click", openHistoryModal); + document + .getElementById("historyModalClose") + .addEventListener("click", closeHistoryModal); + document.getElementById("historyModal").addEventListener("click", (e) => { + if (e.target.id === "historyModal") closeHistoryModal(); + }); + // Refresh all button document .getElementById("refreshAllBtn") diff --git a/web/static/index.html b/web/static/index.html index ebe2679f..0aaaf5b6 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -58,6 +58,7 @@
+
@@ -144,6 +145,22 @@ 正在获取气象数据,请稍候... + + + diff --git a/web/static/style.css b/web/static/style.css index 479d5c31..0e11ee63 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -1032,3 +1032,110 @@ body { font-size: 14px; } } + +/* ── History Modal ── */ +.history-btn { + background: rgba(34, 211, 238, 0.1); + color: var(--accent-cyan); + border: 1px solid rgba(34, 211, 238, 0.3); + padding: 4px 10px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + margin-left: 8px; +} +.history-btn:hover { + background: rgba(34, 211, 238, 0.2); + border-color: var(--accent-cyan); +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.modal-content { + background: #111827; + border: 1px solid var(--border-subtle); + border-radius: 16px; + width: 100%; + max-width: 700px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; +} + +.modal-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + justify-content: space-between; + align-items: center; +} +.modal-header h2 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} +.modal-close { + background: none; + border: none; + font-size: 20px; + color: var(--text-muted); + cursor: pointer; + transition: color 0.2s; +} +.modal-close:hover { + color: var(--accent-red); +} + +.modal-body { + padding: 20px; +} + +.history-stats { + display: flex; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; +} +.h-stat-card { + flex: 1; + min-width: 110px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 12px; + text-align: center; +} +.h-stat-card .label { + display: block; + font-size: 11px; + color: var(--text-muted); + margin-bottom: 4px; +} +.h-stat-card .val { + display: block; + font-size: 18px; + font-weight: 700; + color: var(--text-primary); +} + +.history-chart-wrapper { + position: relative; + height: 300px; + width: 100%; +}