Files
PolyWeather/frontend/components/dashboard/HistoryModal.tsx
T
2026-03-23 23:17:48 +08:00

405 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import { getHistorySummary } from "@/lib/dashboard-utils";
function HistoryChart() {
const store = useDashboardStore();
const { locale } = useI18n();
const { data } = useHistoryData();
const isTaipei = store.selectedCity === "taipei";
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
[data, store.selectedDetail?.local_date],
);
const hasMgm =
store.selectedCity === "ankara" &&
summary.mgms.some((value) => value != null);
const hasBestBaseline =
Boolean(summary.bestModelName) &&
summary.bestModelName !== "MGM" &&
summary.bestModelSeries.some((value) => value != null);
const canvasRef = useChart(() => {
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [
{
backgroundColor: "rgba(248, 113, 113, 0.1)",
borderColor: "#f87171",
borderWidth: 2,
data: summary.actuals,
label: isTaipei
? locale === "en-US"
? "NOAA Settled High (RCTP)"
: "NOAA 结算最高温 (RCTP)"
: locale === "en-US"
? "Observed High"
: "实测最高温",
pointBackgroundColor: "#f87171",
pointBorderColor: "#fff",
pointHoverRadius: 7,
pointRadius: 5,
tension: 0.2,
},
{
backgroundColor: "transparent",
borderColor: "#34d399",
borderDash: [5, 4],
borderWidth: 2,
data: summary.debs,
label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
},
];
if (hasMgm) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#fb923c",
borderWidth: 2,
data: summary.mgms,
label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
if (hasBestBaseline) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#60a5fa",
borderDash: [4, 3],
borderWidth: 2,
data: summary.bestModelSeries,
label:
locale === "en-US"
? `Best Baseline (${summary.bestModelName})`
: `最佳单模型 (${summary.bestModelName})`,
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
return {
data: {
datasets,
labels: summary.dates,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
boxHeight: 12,
boxWidth: 34,
color: "#94a3b8",
font: { family: "Inter", size: 14 },
padding: 18,
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.9)",
borderColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
bodyFont: { family: "Inter", size: 13 },
titleFont: { family: "Inter", size: 13, weight: 600 },
callbacks: {
label: (ctx) =>
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
},
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [hasBestBaseline, hasMgm, isTaipei, summary, locale]);
if (!summary.recentData.length) return null;
return (
<div className="history-chart-wrapper">
<canvas ref={canvasRef} />
</div>
);
}
export function HistoryModal() {
const store = useDashboardStore();
const { t, locale } = useI18n();
const { data, error, isLoading, isOpen } = useHistoryData();
const isPro = store.proAccess.subscriptionActive;
const isProLoading = store.proAccess.loading;
const isTaipei = store.selectedCity === "taipei";
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
[data, store.selectedDetail?.local_date],
);
const settledPeakRows = useMemo(
() =>
summary.recentData
.filter(
(row) =>
row.actual != null &&
row.actual_peak_time &&
row.deb_at_peak_minus_12h != null,
)
.slice(-8)
.reverse(),
[summary.recentData],
);
if (!isOpen) return null;
return (
<div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="history-modal-title"
onClick={(event) => {
if (event.target === event.currentTarget) {
store.closeHistory();
}
}}
>
{isProLoading ? (
<div
className="modal-content"
style={{ padding: "40px", textAlign: "center" }}
>
<div style={{ color: "var(--text-muted)" }}>
{t("dashboard.loading")}
</div>
</div>
) : !isPro ? (
<ProFeaturePaywall feature="history" onClose={store.closeHistory} />
) : (
<div className="modal-content history-modal">
<div className="modal-header">
<h2 id="history-modal-title">
{t("history.title", {
city: store.selectedCity?.toUpperCase() || "",
})}
</h2>
<button
type="button"
className="modal-close"
aria-label={t("history.closeAria")}
onClick={store.closeHistory}
>
×
</button>
</div>
<div className="modal-body">
{isTaipei && (
<div
style={{
marginBottom: "16px",
padding: "12px 14px",
border: "1px solid rgba(56, 189, 248, 0.24)",
borderRadius: "12px",
background: "rgba(14, 165, 233, 0.08)",
color: "var(--text-secondary)",
fontSize: "13px",
lineHeight: 1.6,
}}
>
{t("lang") === "en-US"
? "Taipei historical actuals are aligned to NOAA RCTP settlement rules: use the highest rounded whole-degree Celsius reading after the date is finalized."
: "台北历史对账已按 NOAA RCTP 结算口径对齐:采用该日最终完成质控后的最高整度摄氏值。"}
</div>
)}
<div className="history-stats">
{isLoading ? (
<span style={{ color: "var(--text-muted)" }}>
{t("history.loading")}
</span>
) : error ? (
<span style={{ color: "var(--accent-red)" }}>
{t("history.error")}
</span>
) : !summary.recentData.length ? (
<span style={{ color: "var(--text-muted)" }}>
{t("history.empty")}
</span>
) : (
<>
<div className="h-stat-card">
<span className="label">{t("history.debHitRate")}</span>
<span className="val">
{summary.hitRate != null ? `${summary.hitRate}%` : "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("history.debMae")}</span>
<span className="val">
{summary.debMae != null ? `${summary.debMae}°` : "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("history.bestModelMae")}</span>
<span className="val">
{summary.bestModelMae != null
? `${summary.bestModelMae}°${
summary.bestModelName
? ` (${summary.bestModelName})`
: ""
}`
: "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("history.debVsBest")}</span>
<span className="val">
{summary.debWinRateVsBest != null
? `${summary.debWinRateVsBest}% (${summary.debWinDaysVsBest}/${summary.debVsBestComparableDays})`
: "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("history.sample")}</span>
<span className="val">
{t("history.sampleDays", { count: summary.settledCount })}
</span>
</div>
</>
)}
</div>
{!isLoading && !error && <HistoryChart />}
{!isLoading && !error && settledPeakRows.length > 0 && (
<div
style={{
marginTop: "18px",
padding: "14px 16px",
border: "1px solid rgba(148, 163, 184, 0.14)",
borderRadius: "16px",
background: "rgba(15, 23, 42, 0.42)",
}}
>
<div
style={{
color: "var(--text-primary)",
fontSize: "14px",
fontWeight: 700,
marginBottom: "10px",
}}
>
{locale === "en-US"
? "Peak-12h DEB Reference (Approx.)"
: "峰值前 12 小时 DEB 参考(近似)"}
</div>
<div
style={{
display: "grid",
gap: "8px",
}}
>
{settledPeakRows.map((row) => (
<div
key={row.date}
style={{
display: "grid",
gridTemplateColumns: "minmax(72px, 88px) 1fr",
gap: "10px",
padding: "10px 12px",
borderRadius: "12px",
background: "rgba(255,255,255,0.03)",
}}
>
<div
style={{
color: "var(--text-secondary)",
fontSize: "12px",
fontWeight: 700,
}}
>
{row.date}
</div>
<div
style={{
color: "var(--text-secondary)",
fontSize: "12px",
lineHeight: 1.7,
}}
>
<div>
{locale === "en-US" ? "Peak ref" : "峰值参考"}:{" "}
<span style={{ color: "var(--text-primary)" }}>
{row.actual}
{store.selectedDetail?.temp_symbol || "°C"} @{" "}
{row.actual_peak_time}
</span>
</div>
<div>
{locale === "en-US" ? "DEB@-12h" : "峰值前12小时 DEB"}:{" "}
<span style={{ color: "var(--text-primary)" }}>
{row.deb_at_peak_minus_12h}
{store.selectedDetail?.temp_symbol || "°C"} @{" "}
{row.deb_at_peak_minus_12h_time}
</span>
</div>
<div>
{locale === "en-US" ? "Actual" : "最终实测"}:{" "}
<span style={{ color: "var(--text-primary)" }}>
{row.actual}
{store.selectedDetail?.temp_symbol || "°C"}
</span>
</div>
<div>
{locale === "en-US" ? "Error" : "误差"}:{" "}
<span
style={{
color:
(row.deb_at_peak_minus_12h_error ?? 0) > 0
? "#f59e0b"
: "#34d399",
}}
>
{row.deb_at_peak_minus_12h_error != null
? `${row.deb_at_peak_minus_12h_error > 0 ? "+" : ""}${row.deb_at_peak_minus_12h_error}${store.selectedDetail?.temp_symbol || "°C"}`
: "--"}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}