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%;
+}