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)
|
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
|
# 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
|
// Init
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
@@ -834,6 +992,17 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
if (e.key === "Escape") closePanel();
|
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
|
// Refresh all button
|
||||||
document
|
document
|
||||||
.getElementById("refreshAllBtn")
|
.getElementById("refreshAllBtn")
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
<div class="panel-meta">
|
<div class="panel-meta">
|
||||||
<span id="panelRiskBadge" class="risk-badge">—</span>
|
<span id="panelRiskBadge" class="risk-badge">—</span>
|
||||||
<span id="panelLocalTime" class="local-time">—</span>
|
<span id="panelLocalTime" class="local-time">—</span>
|
||||||
|
<button class="history-btn" id="btnShowHistory" title="查看历史记录与准确率">📊 历史对账</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,6 +145,22 @@
|
|||||||
<span>正在获取气象数据,请稍候...</span>
|
<span>正在获取气象数据,请稍候...</span>
|
||||||
</div>
|
</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 -->
|
<!-- Leaflet JS -->
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<!-- Chart.js -->
|
<!-- Chart.js -->
|
||||||
|
|||||||
@@ -1032,3 +1032,110 @@ body {
|
|||||||
font-size: 14px;
|
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