feat: add historical accuracy chart and modal to web UI
This commit is contained in:
+32
@@ -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
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user