feat: add historical accuracy chart and modal to web UI

This commit is contained in:
2569718930@qq.com
2026-03-04 03:11:21 +08:00
parent d5b546528c
commit 807ccba89c
4 changed files with 325 additions and 0 deletions
+32
View File
@@ -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
# ──────────────────────────────────────────────────────────
+169
View File
@@ -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 =
'<span style="color:var(--text-muted)">正在获取底层数据库...</span>';
try {
const res = await fetch(`/api/history/${encodeURIComponent(selectedCity)}`);
const json = await res.json();
const data = json.history || [];
if (data.length === 0) {
statsDiv.innerHTML =
'<span style="color:var(--text-muted)">暂无该城市历史数据</span>';
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 = `
<div class="h-stat-card"><span class="label">DEB 结算胜率 (WU)</span><span class="val">${hitRate}%</span></div>
<div class="h-stat-card"><span class="label">DEB MAE</span><span class="val">${debMae}°</span></div>
<div class="h-stat-card"><span class="label">μ (概率) MAE</span><span class="val">${muMae}°</span></div>
<div class="h-stat-card"><span class="label">有效样本数</span><span class="val">${data.length}天</span></div>
`;
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 =
'<span style="color:var(--accent-red)">获取历史信息失败</span>';
}
}
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")
+17
View File
@@ -58,6 +58,7 @@
<div class="panel-meta">
<span id="panelRiskBadge" class="risk-badge"></span>
<span id="panelLocalTime" class="local-time"></span>
<button class="history-btn" id="btnShowHistory" title="查看历史记录与准确率">📊 历史对账</button>
</div>
</div>
</div>
@@ -144,6 +145,22 @@
<span>正在获取气象数据,请稍候...</span>
</div>
<!-- ── History Chart Modal ── -->
<div id="historyModal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<h2>📊 <span id="historyModalTitle">历史准确率</span></h2>
<button class="modal-close" id="historyModalClose"></button>
</div>
<div class="modal-body">
<div class="history-stats" id="historyStats"></div>
<div class="history-chart-wrapper">
<canvas id="historyChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Chart.js -->
+107
View File
@@ -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%;
}