DEB预报曲线改为平滑monotone渲染,时间轴每2小时一个刻度;修复TrainingDashboard类型错误

This commit is contained in:
2569718930@qq.com
2026-05-25 04:57:03 +08:00
parent 0edcc11d3f
commit ece77d7595
2 changed files with 202 additions and 50 deletions
@@ -25,6 +25,7 @@ type EvidenceSeries = {
color: string;
dashed?: boolean;
featured?: boolean;
smooth?: boolean;
values: Array<number | null>;
};
@@ -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({
<ResponsiveContainer width="100%" height="100%">
<ReLineChart data={data} margin={{ top: 16, right: 28, left: 8, bottom: 8 }}>
<CartesianGrid stroke="#dbe6ef" strokeDasharray="2 2" />
<XAxis dataKey="label" tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={{ stroke: "#cbd5e1" }} />
<XAxis dataKey="label" tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={{ stroke: "#cbd5e1" }} interval={2} />
<YAxis tick={{ fontSize: 10, fill: "#64748b" }} tickFormatter={(v) => `${Number(v).toFixed(1)}°`} orientation="right" axisLine={{ stroke: "#cbd5e1" }} tickLine={false} />
{threshold !== null && (
<ReferenceLine
@@ -351,7 +353,7 @@ export function LiveTemperatureThresholdChart({
dot={false}
isAnimationActive={false}
name={item.label}
type="stepAfter"
type={item.smooth ? "monotone" : "stepAfter"}
/>
))}
</ReLineChart>
@@ -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<TrainingCity[] | null>(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 (
<div className="h-full overflow-auto bg-white p-6">
<div className="mx-auto max-w-5xl">
<h1 className="text-xl font-black text-slate-900">
{isEn ? "DEB Training Accuracy" : "DEB 训练数据准确率"}
</h1>
<p className="mt-1 text-sm text-slate-500">
{isEn
? "Per-city DEB prediction accuracy. Hit rate measures whether the forecast fell within the settlement window."
: "各城市 DEB 预报命中率。命中率衡量预报是否落入结算接受窗口。"}
</p>
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 (
<div className="h-full overflow-auto bg-[#f5f7fa]">
<div className="p-4">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-lg font-black text-slate-900 flex items-center gap-2">
<Target size={18} className="text-blue-600" />
{isEn ? "DEB Training Accuracy" : "DEB 训练数据准确率"}
</h1>
<p className="mt-0.5 text-xs text-slate-500">
{isEn
? "DEB hit rate = forecast within settlement acceptance window"
: "DEB 命中率 = 预报落入结算接受窗口的比例"}
</p>
</div>
{stats && (
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>{isEn ? "Updated" : "更新时间"}: {new Date().toLocaleDateString()}</span>
</div>
)}
</div>
{/* Stats cards */}
{stats && (
<div className="mt-6 grid grid-cols-4 gap-3">
<div className="grid grid-cols-4 gap-2 mb-4">
{[
[isEn ? "Cities" : "城市", stats.cities],
[isEn ? "Avg Hit" : "平均命中率", `${stats.avgHit.toFixed(1)}%`],
["Avg MAE", `${stats.avgMae.toFixed(1)}°`],
[isEn ? "Days" : "训练天数", stats.totalDays],
].map(([label, value]) => (
<div key={String(label)} className="rounded border border-slate-200 bg-slate-50 p-4">
<div className="text-[11px] font-bold uppercase text-slate-400">{label}</div>
<div className="mt-1 font-mono text-xl font-black text-slate-900">{String(value)}</div>
{ 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 }) => (
<div key={label} className={`flex items-center gap-3 rounded-lg border ${bg} p-3`}>
<Icon size={20} className={color} />
<div>
<div className="text-[10px] font-bold uppercase text-slate-500">{label}</div>
<div className="font-mono text-lg font-black text-slate-900">{String(value)}</div>
</div>
</div>
))}
</div>
)}
<div className="mt-6 rounded border border-slate-200 overflow-hidden">
<table className="w-full text-[13px] border-collapse">
{/* Distribution summary */}
{stats && (
<div className="grid grid-cols-3 gap-2 mb-4">
{[
{ 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 }) => (
<div key={color} className={`rounded-lg border border-${color}-200 bg-${color}-50 p-3 text-center`}>
<div className={`font-mono text-2xl font-black text-${color}-600`}>{count}</div>
<div className="text-[10px] font-bold uppercase text-slate-500">{label}</div>
</div>
))}
</div>
)}
{/* Charts */}
{chartData.length > 0 && (
<div className="grid grid-cols-2 gap-3 mb-4">
{/* Hit rate bar chart */}
<div className="rounded-lg border border-slate-200 bg-white p-3">
<h2 className="mb-2 text-[11px] font-black uppercase text-slate-500">
{isEn ? "Hit Rate by City" : "各城市命中率"}
</h2>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
<XAxis type="number" domain={[0, 100]} tick={{ fontSize: 10, fill: "#64748b" }} tickFormatter={(v) => `${v}%`} />
<YAxis dataKey="name" type="category" tick={{ fontSize: 10, fill: "#334155" }} width={50} />
<Tooltip
contentStyle={{ borderRadius: 6, border: "1px solid #e2e8f0", fontSize: 12 }}
formatter={(value) => [`${Number(value)}%`, isEn ? "Hit Rate" : "命中率"]}
/>
<Bar dataKey="hit" radius={[0, 3, 3, 0]} fill="#2563eb" barSize={14} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* MAE bar chart (lower is better) */}
<div className="rounded-lg border border-slate-200 bg-white p-3">
<h2 className="mb-2 text-[11px] font-black uppercase text-slate-500">
{isEn ? "MAE by City (lower = better)" : "各城市 MAE (越低越好)"}
</h2>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={maeChartData} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: "#64748b" }} tickFormatter={(v) => `${v}°`} />
<YAxis dataKey="name" type="category" tick={{ fontSize: 10, fill: "#334155" }} width={50} />
<Tooltip
contentStyle={{ borderRadius: 6, border: "1px solid #e2e8f0", fontSize: 12 }}
formatter={(value) => [`${Number(value)}°`, "MAE"]}
/>
<Bar dataKey="mae" radius={[0, 3, 3, 0]} fill="#7c3aed" barSize={14} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
)}
{/* Table */}
<div className="rounded-lg border border-slate-200 bg-white overflow-hidden">
<table className="w-full text-[12px] border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-left text-slate-500">
<th className="px-4 py-2 font-bold">{isEn ? "City" : "城市"}</th>
<th className="px-4 py-2 text-right font-bold">{isEn ? "Hit Rate" : "命中率"}</th>
<th className="px-4 py-2 text-right font-bold">MAE</th>
<th className="px-4 py-2 text-right font-bold">{isEn ? "Days" : "训练天数"}</th>
<tr className="border-b border-slate-200 bg-[#f8f9fa] text-left">
<th className="w-10 px-3 py-2 text-center text-[10px] font-black text-slate-400">#</th>
<th className="px-3 py-2 text-[10px] font-black uppercase text-slate-500">
{isEn ? "City" : "城市"}
</th>
<th className="px-3 py-2 text-right text-[10px] font-black uppercase text-slate-500">
{isEn ? "Hit Rate" : "命中率"}
</th>
<th className="px-3 py-2 text-right text-[10px] font-black uppercase text-slate-500">MAE</th>
<th className="px-3 py-2 text-right text-[10px] font-black uppercase text-slate-500">
{isEn ? "Days" : "训练天数"}
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{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 (
<tr key={c.city_id} className="hover:bg-slate-50">
<td className="px-4 py-1.5 font-semibold capitalize">{c.name}</td>
<td className="px-4 py-1.5" style={{ minWidth: 180 }}>
<tr key={c.city_id} className="hover:bg-slate-50 transition-colors">
<td className="px-3 py-2 text-center text-[10px] font-mono text-slate-400">{i + 1}</td>
<td className="px-3 py-2 font-semibold text-slate-800 capitalize">{c.name}</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-2">
<div className="w-24 h-1.5 rounded-full bg-slate-100 overflow-hidden">
<div className="relative h-2 w-28 overflow-hidden rounded-full bg-slate-100">
<div
className={`h-full rounded-full ${
hr >= 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)}%` }}
/>
<div
className="absolute inset-y-0 left-0 rounded-full opacity-30"
style={{
width: `${Math.min(100, hr)}%`,
background: `repeating-linear-gradient(90deg, transparent, transparent 3px, rgba(255,255,255,0.3) 3px, rgba(255,255,255,0.3) 6px)`,
}}
/>
</div>
<span className={`font-mono font-bold text-[11px] ${
hr >= 60 ? "text-emerald-700" : hr >= 30 ? "text-amber-700" : "text-red-600"
}`}>
<span className={`w-10 text-right font-mono text-[11px] font-bold text-${color}-600`}>
{hr.toFixed(0)}%
</span>
</div>
</td>
<td className="px-4 py-1.5 text-right font-mono">
{(c.deb?.mae ?? 0).toFixed(1)}°
</td>
<td className="px-4 py-1.5 text-right text-slate-400">
{c.deb?.total_days ?? 0}
</td>
<td className="px-3 py-2 text-right font-mono text-slate-600">{mae.toFixed(1)}°</td>
<td className="px-3 py-2 text-right text-slate-400">{c.deb?.total_days ?? 0}</td>
</tr>
);
}) : (
<tr>
<td colSpan={4} className="px-4 py-10 text-center text-slate-400">
{data === null ? (isEn ? "Loading..." : "加载中...") : (isEn ? "No training data" : "暂无训练数据")}
<td colSpan={5} className="px-4 py-12 text-center text-slate-400">
{data === null
? (isEn ? "Loading training data..." : "加载训练数据中...")
: (isEn ? "No training data available" : "暂无训练数据")}
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Footer note */}
<p className="mt-3 text-center text-[10px] text-slate-400">
{isEn
? "Training data updated daily. Hit rate = DEB prediction within settlement acceptance range."
: "训练数据每日更新。命中率 = DEB 预报落入结算接受窗口的比例。"}
</p>
</div>
</div>
);