diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 1f96b327..4b5ad28b 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -25,6 +25,7 @@ type EvidenceSeries = { color: string; dashed?: boolean; featured?: boolean; + smooth?: boolean; values: Array; }; @@ -103,6 +104,7 @@ function buildModelCurves(row: ScanOpportunityRow | null, length: number, hourly source: "DEB Hourly", color: "#f97316", featured: true, + smooth: true, values, }); } @@ -322,7 +324,7 @@ export function LiveTemperatureThresholdChart({ - + `${Number(v).toFixed(1)}°`} orientation="right" axisLine={{ stroke: "#cbd5e1" }} tickLine={false} /> {threshold !== null && ( ))} diff --git a/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx b/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx index 29622933..743c6dc7 100644 --- a/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx +++ b/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx @@ -1,6 +1,16 @@ "use client"; import { useEffect, useMemo, useState } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { TrendingUp, Target, Thermometer, Hash } from "lucide-react"; type TrainingCity = { city_id: string; @@ -8,6 +18,20 @@ type TrainingCity = { deb?: { hit_rate: number; mae: number; total_days: number } | null; }; +const CHART_COLORS = { + high: "#059669", + mid: "#d97706", + low: "#dc2626", + blue: "#2563eb", + purple: "#7c3aed", +}; + +function barColor(hr: number) { + if (hr >= 65) return CHART_COLORS.high; + if (hr >= 45) return CHART_COLORS.mid; + return CHART_COLORS.low; +} + export function TrainingDashboard({ isEn }: { isEn: boolean }) { const [data, setData] = useState(null); @@ -19,7 +43,7 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) { }) .then(async (res) => { if (!res.ok) return null; - return (res.json() as Promise<{ accuracy: TrainingCity[] }>); + return res.json() as Promise<{ accuracy: TrainingCity[] }>; }) .then((payload) => { if (cancelled || !payload?.accuracy) return; @@ -39,88 +63,214 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) { const avgHit = debSorted.reduce((s, c) => s + (c.deb?.hit_rate ?? 0), 0) / debSorted.length; const avgMae = debSorted.reduce((s, c) => s + (c.deb?.mae ?? 0), 0) / debSorted.length; const totalDays = debSorted.reduce((s, c) => s + (c.deb?.total_days ?? 0), 0); - return { avgHit, avgMae, totalDays, cities: debSorted.length }; + const high = debSorted.filter((c) => (c.deb?.hit_rate ?? 0) >= 65).length; + const mid = debSorted.filter((c) => { + const hr = c.deb?.hit_rate ?? 0; + return hr >= 45 && hr < 65; + }).length; + const low = debSorted.filter((c) => (c.deb?.hit_rate ?? 0) < 45).length; + return { avgHit, avgMae, totalDays, cities: debSorted.length, high, mid, low }; }, [debSorted]); - return ( -
-
-

- {isEn ? "DEB Training Accuracy" : "DEB 训练数据准确率"} -

-

- {isEn - ? "Per-city DEB prediction accuracy. Hit rate measures whether the forecast fell within the settlement window." - : "各城市 DEB 预报命中率。命中率衡量预报是否落入结算接受窗口。"} -

+ const chartData = useMemo( + () => + debSorted.slice(0, 18).map((c) => ({ + name: c.name, + hit: Number((c.deb?.hit_rate ?? 0).toFixed(1)), + mae: Number((c.deb?.mae ?? 0).toFixed(2)), + fill: barColor(c.deb?.hit_rate ?? 0), + })), + [debSorted], + ); + const maeChartData = useMemo( + () => + [...debSorted] + .sort((a, b) => (a.deb?.mae ?? 99) - (b.deb?.mae ?? 99)) + .slice(0, 18) + .map((c) => ({ + name: c.name, + mae: Number((c.deb?.mae ?? 0).toFixed(2)), + })), + [debSorted], + ); + + return ( +
+
+ {/* Header */} +
+
+

+ + {isEn ? "DEB Training Accuracy" : "DEB 训练数据准确率"} +

+

+ {isEn + ? "DEB hit rate = forecast within settlement acceptance window" + : "DEB 命中率 = 预报落入结算接受窗口的比例"} +

+
+ {stats && ( +
+ {isEn ? "Updated" : "更新时间"}: {new Date().toLocaleDateString()} +
+ )} +
+ + {/* Stats cards */} {stats && ( -
+
{[ - [isEn ? "Cities" : "城市数", stats.cities], - [isEn ? "Avg Hit" : "平均命中率", `${stats.avgHit.toFixed(1)}%`], - ["Avg MAE", `${stats.avgMae.toFixed(1)}°`], - [isEn ? "Days" : "训练天数", stats.totalDays], - ].map(([label, value]) => ( -
-
{label}
-
{String(value)}
+ { icon: Hash, label: isEn ? "Models" : "城市模型", value: stats.cities, color: "text-blue-600", bg: "bg-blue-50 border-blue-200" }, + { icon: Target, label: isEn ? "Avg Hit Rate" : "平均命中率", value: `${stats.avgHit.toFixed(1)}%`, color: "text-emerald-600", bg: "bg-emerald-50 border-emerald-200" }, + { icon: Thermometer, label: "Avg MAE", value: `${stats.avgMae.toFixed(1)}°`, color: "text-amber-600", bg: "bg-amber-50 border-amber-200" }, + { icon: TrendingUp, label: isEn ? "Total Days" : "总训练天数", value: stats.totalDays.toLocaleString(), color: "text-purple-600", bg: "bg-purple-50 border-purple-200" }, + ].map(({ icon: Icon, label, value, color, bg }) => ( +
+ +
+
{label}
+
{String(value)}
+
))}
)} -
- + {/* Distribution summary */} + {stats && ( +
+ {[ + { label: isEn ? "High (≥65%)" : "高 (≥65%)", count: stats.high, color: "emerald" }, + { label: isEn ? "Mid (45-64%)" : "中 (45-64%)", count: stats.mid, color: "amber" }, + { label: isEn ? "Low (<45%)" : "低 (<45%)", count: stats.low, color: "red" }, + ].map(({ label, count, color }) => ( +
+
{count}
+
{label}
+
+ ))} +
+ )} + + {/* Charts */} + {chartData.length > 0 && ( +
+ {/* Hit rate bar chart */} +
+

+ {isEn ? "Hit Rate by City" : "各城市命中率"} +

+
+ + + + `${v}%`} /> + + [`${Number(value)}%`, isEn ? "Hit Rate" : "命中率"]} + /> + + + +
+
+ + {/* MAE bar chart (lower is better) */} +
+

+ {isEn ? "MAE by City (lower = better)" : "各城市 MAE (越低越好)"} +

+
+ + + + `${v}°`} /> + + [`${Number(value)}°`, "MAE"]} + /> + + + +
+
+
+ )} + + {/* Table */} +
+
- - - - - + + + + + + - {debSorted.length ? debSorted.map((c) => { + {debSorted.length ? debSorted.map((c, i) => { const hr = c.deb?.hit_rate ?? 0; + const mae = c.deb?.mae ?? 0; + const color = hr >= 65 ? "emerald" : hr >= 45 ? "amber" : "red"; return ( - - - + + + - - + + ); }) : ( - )}
{isEn ? "City" : "城市"}{isEn ? "Hit Rate" : "命中率"}MAE{isEn ? "Days" : "训练天数"}
# + {isEn ? "City" : "城市"} + + {isEn ? "Hit Rate" : "命中率"} + MAE + {isEn ? "Days" : "训练天数"} +
{c.name} +
{i + 1}{c.name}
-
+
= 60 ? "bg-emerald-500" : hr >= 30 ? "bg-amber-400" : "bg-red-400" - }`} + className={`absolute inset-y-0 left-0 rounded-full bg-${color}-500 transition-all`} style={{ width: `${Math.min(100, hr)}%` }} /> +
- = 60 ? "text-emerald-700" : hr >= 30 ? "text-amber-700" : "text-red-600" - }`}> + {hr.toFixed(0)}%
- {(c.deb?.mae ?? 0).toFixed(1)}° - - {c.deb?.total_days ?? 0} - {mae.toFixed(1)}°{c.deb?.total_days ?? 0}
- {data === null ? (isEn ? "Loading..." : "加载中...") : (isEn ? "No training data" : "暂无训练数据")} + + {data === null + ? (isEn ? "Loading training data..." : "加载训练数据中...") + : (isEn ? "No training data available" : "暂无训练数据")}
+ + {/* Footer note */} +

+ {isEn + ? "Training data updated daily. Hit rate = DEB prediction within settlement acceptance range." + : "训练数据每日更新。命中率 = DEB 预报落入结算接受窗口的比例。"} +

);