feat: Implement the PolyWeather dashboard including frontend components, data collection, analysis, and API endpoints.

This commit is contained in:
2569718930@qq.com
2026-03-10 04:45:40 +08:00
parent 020c62676e
commit aab4477ab3
24 changed files with 2835 additions and 524 deletions
@@ -2,9 +2,11 @@
import clsx from "clsx";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
export function CitySidebar() {
const store = useDashboardStore();
const { t } = useI18n();
const sortedCities = [...store.cities].sort((a, b) => {
const order = { high: 0, medium: 1, low: 2 };
return (
@@ -16,7 +18,7 @@ export function CitySidebar() {
return (
<nav className="city-list">
<div className="city-list-header">
<span></span>
<span>{t("sidebar.title")}</span>
<span className="city-count">{store.cities.length}</span>
</div>
@@ -45,17 +47,17 @@ export function CitySidebar() {
>
{snapshot?.current?.temp != null
? `${snapshot.current.temp}${snapshot.temp_symbol || "°C"}`
: "--"}
: t("common.na")}
</span>
</div>
<div className="city-item-info">
<span className="city-local-time">
{snapshot?.local_time ? `🕐 ${snapshot.local_time}` : ""}
{snapshot?.local_time ? `🕒 ${snapshot.local_time}` : ""}
</span>
<span className="city-max-info">
{detail?.current?.max_temp_time
? `峰值 @ ${detail.current.max_temp_time}`
? t("sidebar.peakAt", { time: detail.current.max_temp_time })
: ""}
</span>
</div>
@@ -174,6 +174,40 @@
gap: 12px;
}
.root :global(.lang-switch) {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px;
border-radius: 10px;
border: 1px solid var(--border-glass);
background: var(--bg-glass);
}
.root :global(.lang-btn) {
border: none;
background: transparent;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
line-height: 1;
padding: 6px 8px;
border-radius: 7px;
cursor: pointer;
transition: var(--transition);
}
.root :global(.lang-btn:hover) {
color: var(--text-primary);
background: rgba(99, 102, 241, 0.12);
}
.root :global(.lang-btn.active) {
color: var(--text-primary);
background: rgba(34, 211, 238, 0.16);
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.26);
}
.root :global(.live-badge) {
display: flex;
align-items: center;
@@ -1333,6 +1367,12 @@
overflow: hidden;
}
.root :global(.modal-content.history-modal) {
width: min(96vw, 1180px);
max-width: 1180px;
max-height: calc(100vh - 32px);
}
.root :global(.modal-header) {
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
@@ -1363,6 +1403,10 @@
overflow-y: auto;
}
.root :global(.history-modal .modal-body) {
padding: 24px 28px 28px;
}
.root :global(.history-stats) {
display: flex;
gap: 12px;
@@ -1397,6 +1441,78 @@
width: 100%;
}
.root :global(.history-modal .history-stats) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.root :global(.history-modal .h-stat-card) {
min-width: 0;
padding: 16px 18px;
border-radius: 12px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.035) 0%,
rgba(255, 255, 255, 0.02) 100%
);
}
.root :global(.history-modal .h-stat-card .label) {
font-size: 12px;
margin-bottom: 8px;
}
.root :global(.history-modal .h-stat-card .val) {
font-size: 32px;
line-height: 1.1;
letter-spacing: -0.02em;
}
.root :global(.history-modal .history-chart-wrapper) {
height: 420px;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 12px 14px 8px;
}
.root :global(.history-modal .history-chart-wrapper canvas) {
width: 100% !important;
height: 100% !important;
}
@media (max-width: 980px) {
.root :global(.history-modal .modal-body) {
padding: 18px;
}
.root :global(.history-modal .history-stats) {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.root :global(.history-modal .history-chart-wrapper) {
height: 360px;
}
}
@media (max-width: 640px) {
.root :global(.history-modal .history-stats) {
grid-template-columns: 1fr;
}
.root :global(.history-modal .h-stat-card .val) {
font-size: 28px;
}
.root :global(.history-modal .history-chart-wrapper) {
height: 300px;
padding: 8px 10px 6px;
}
}
/* ── Info Button ── */
.root :global(.info-btn) {
background: rgba(99, 102, 241, 0.1);
@@ -1676,6 +1792,31 @@
line-height: 1.7;
}
.root :global(.detail-mini-chart-wrap) {
display: grid;
gap: 8px;
}
.root :global(.detail-mini-chart) {
position: relative;
height: 190px;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 10px;
}
.root :global(.detail-mini-chart canvas) {
width: 100% !important;
height: 100% !important;
}
.root :global(.detail-mini-meta) {
color: var(--text-muted);
font-size: 11px;
line-height: 1.55;
}
.root :global(.insight-list) {
display: grid;
gap: 10px;
+135 -46
View File
@@ -1,18 +1,131 @@
"use client";
import { ChartConfiguration } from "chart.js/auto";
import clsx from "clsx";
import { ForecastTable } from "@/components/dashboard/PanelSections";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getCityScenery } from "@/lib/dashboard-scenery";
import { CityDetail } from "@/lib/dashboard-types";
import {
getCityProfileStats,
getClimateDrivers,
getRiskBadgeLabel,
getSettlementRiskNarrative,
getTemperatureChartData,
} from "@/lib/dashboard-utils";
import { ForecastTable } from "@/components/dashboard/PanelSections";
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale, t } = useI18n();
const chartData = getTemperatureChartData(detail, locale);
const canvasRef = useChart(
() => {
if (!chartData) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
const forecastPoints = chartData.datasets.hasMgmHourly
? chartData.datasets.mgmHourlyPoints
: chartData.datasets.debPast.map(
(value, index) => value ?? chartData.datasets.debFuture[index],
);
return {
data: {
datasets: [
{
borderColor: chartData.datasets.hasMgmHourly
? "rgba(250, 204, 21, 0.92)"
: "rgba(52, 211, 153, 0.86)",
borderWidth: 1.8,
data: forecastPoints,
fill: false,
label: chartData.datasets.hasMgmHourly
? locale === "en-US"
? "MGM Forecast"
: "MGM 预测"
: locale === "en-US"
? "DEB Forecast"
: "DEB 预测",
pointRadius: 0,
spanGaps: true,
tension: 0.28,
},
{
backgroundColor: "#22d3ee",
borderColor: "#22d3ee",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
pointHoverRadius: 6,
pointRadius: 3.8,
showLine: false,
},
],
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.95)",
borderColor: "rgba(34, 211, 238, 0.25)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.03)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 4 === 0
? chartData.times[index]
: "",
color: "#64748b",
font: { size: 10 },
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.03)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
color: "#64748b",
font: { size: 10 },
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
},
[chartData, detail.temp_symbol, locale],
);
return (
<div className="detail-mini-chart-wrap">
<div className="detail-mini-chart">
<canvas ref={canvasRef} />
</div>
<div className="detail-mini-meta">
{chartData?.legendText || t("detail.chartLegendEmpty")}
</div>
</div>
);
}
export function DetailPanel() {
const store = useDashboardStore();
const { locale, t } = useI18n();
const detail = store.selectedDetail;
const isOverlayOpen =
Boolean(store.futureModalDate) ||
@@ -24,9 +137,7 @@ export function DetailPanel() {
Boolean(detail) &&
!store.loadingState.cityDetail &&
!isOverlayOpen;
const profileStats = detail ? getCityProfileStats(detail) : [];
const riskLines = detail ? getSettlementRiskNarrative(detail) : [];
const climateDrivers = detail ? getClimateDrivers(detail) : [];
const profileStats = detail ? getCityProfileStats(detail, locale) : [];
const scenery = getCityScenery(detail?.name);
return (
@@ -38,39 +149,39 @@ export function DetailPanel() {
<button
type="button"
className="panel-close"
aria-label="关闭城市详情面板"
aria-label={t("detail.closeAria")}
onClick={store.closePanel}
>
×
</button>
<div className="panel-title-area">
<h2>{detail?.display_name?.toUpperCase() || ""}</h2>
<h2>{detail?.display_name?.toUpperCase() || "..."}</h2>
<div className="panel-meta">
<span className={clsx("risk-badge", detail?.risk?.level || "low")}>
{getRiskBadgeLabel(detail?.risk?.level)}
{getRiskBadgeLabel(detail?.risk?.level, locale)}
</span>
<span className="local-time">
{detail
? `${detail.local_date} ${detail.local_time}`
: "等待选择城市"}
: t("detail.waitSelect")}
</span>
<button
type="button"
className="history-btn"
title="查看今日日内分析"
onClick={store.openTodayModal}
title={t("detail.todayAnalysis")}
onClick={() => void store.openTodayModal()}
disabled={!detail}
>
{t("detail.todayAnalysis")}
</button>
<button
type="button"
className="history-btn"
title="查看历史对账"
title={t("detail.history")}
onClick={() => void store.openHistory()}
disabled={!detail}
>
{t("detail.history")}
</button>
</div>
</div>
@@ -81,8 +192,8 @@ export function DetailPanel() {
<section>
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
{store.loadingState.cityDetail
? "正在加载城市详情..."
: "从左侧城市列表选择一个城市查看详情。"}
? t("detail.loading")
: t("detail.emptyHint")}
</div>
</section>
) : (
@@ -93,7 +204,7 @@ export function DetailPanel() {
<img
className="detail-scenery-image"
src={scenery.imageUrl}
alt={`${detail.display_name} 风景照`}
alt={t("detail.sceneryAlt", { city: detail.display_name })}
/>
<div className="detail-scenery-overlay">
<div className="detail-scenery-copy">
@@ -113,21 +224,19 @@ export function DetailPanel() {
</>
) : (
<div className="detail-scenery-fallback">
<span className="detail-scenery-kicker">
{detail.display_name}
</span>
<span className="detail-scenery-kicker">{detail.display_name}</span>
<strong className="detail-scenery-title">
{t("detail.sceneryTitle")}
</strong>
<span className="detail-scenery-subtitle">
{t("detail.sceneryFallback")}
</span>
</div>
)}
</section>
<section className="detail-section">
<h3></h3>
<h3>{t("detail.profile")}</h3>
<div className="detail-grid">
{profileStats.map((item) => (
<div key={item.label} className="detail-card">
@@ -139,30 +248,10 @@ export function DetailPanel() {
</section>
<section className="detail-section">
<h3></h3>
<div className="risk-info">
{riskLines.map((line) => (
<div key={line} className="risk-row">
<span style={{ color: "var(--accent-cyan)", opacity: 0.6 }}>
</span>
<span>{line}</span>
</div>
))}
</div>
<h3>{t("detail.todayMiniTrend")}</h3>
<DetailMiniTemperatureChart detail={detail} />
</section>
<section className="detail-section">
<h3></h3>
<div className="insight-list">
{climateDrivers.map((driver) => (
<div key={driver.label} className="insight-item">
<div className="insight-title">{driver.label}</div>
<div className="insight-text">{driver.text}</div>
</div>
))}
</div>
</section>
<ForecastTable />
</>
)}
@@ -5,34 +5,29 @@ import clsx from "clsx";
import { CSSProperties } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import {
ModelForecast,
ProbabilityDistribution,
} from "@/components/dashboard/PanelSections";
import {
getClimateDrivers,
getFutureModalView,
getSettlementRiskNarrative,
getShortTermNowcastLines,
getTemperatureChartData,
getWeatherSummary,
parseAiAnalysis,
} from "@/lib/dashboard-utils";
function getConfidenceLabel(confidence: string) {
return (
{
high: "高",
medium: "中",
low: "低",
}[confidence] || confidence
);
}
function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
const store = useDashboardStore();
const { locale, t } = useI18n();
const detail = store.selectedDetail;
const view = detail ? getFutureModalView(detail, dateStr) : null;
const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
const isToday = detail ? dateStr === detail.local_date : false;
const todayChartData = detail && isToday ? getTemperatureChartData(detail) : null;
const todayChartData =
detail && isToday ? getTemperatureChartData(detail, locale) : null;
const canvasRef = useChart(
() => {
@@ -53,7 +48,7 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
borderWidth: 2,
data: todayChartData.datasets.mgmHourlyPoints,
fill: false,
label: "MGM 预报",
label: locale === "en-US" ? "MGM Forecast" : "MGM 预报",
pointHoverRadius: 6,
pointRadius: 3,
spanGaps: true,
@@ -66,7 +61,7 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
borderWidth: 1.5,
data: todayChartData.datasets.debPast,
fill: true,
label: "DEB 预报",
label: locale === "en-US" ? "DEB Forecast" : "DEB 预报",
pointHoverRadius: 3,
pointRadius: 0,
tension: 0.3,
@@ -77,7 +72,7 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
borderWidth: 1.5,
data: todayChartData.datasets.debFuture,
fill: false,
label: "DEB 预报",
label: locale === "en-US" ? "DEB Forecast" : "DEB 预报",
pointRadius: 0,
tension: 0.3,
});
@@ -89,7 +84,7 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
borderWidth: 0,
data: todayChartData.datasets.metarPoints,
fill: false,
label: "METAR 实测",
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
order: 0,
pointHoverRadius: 7,
pointRadius: 5,
@@ -102,7 +97,7 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
borderWidth: 0,
data: todayChartData.datasets.mgmPoints,
fill: false,
label: "MGM 实测",
label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
order: -1,
pointHoverRadius: 9,
pointRadius: 7,
@@ -120,7 +115,7 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
borderWidth: 1,
data: todayChartData.datasets.temps,
fill: false,
label: "OM 原始",
label: locale === "en-US" ? "OM Raw" : "OM 原始",
pointRadius: 0,
tension: 0.3,
});
@@ -198,7 +193,7 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
borderColor: "#22d3ee",
data: view.slice.map((point) => point.temp),
fill: false,
label: "Open-Meteo 温度",
label: locale === "en-US" ? "Open-Meteo Temperature" : "Open-Meteo 温度",
pointRadius: 2,
tension: 0.28,
},
@@ -208,7 +203,7 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
borderDash: [5, 4],
data: view.slice.map((point) => point.dewPoint),
fill: false,
label: "露点",
label: locale === "en-US" ? "Dew Point" : "露点",
pointRadius: 0,
tension: 0.24,
},
@@ -258,7 +253,7 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
type: "line",
} satisfies ChartConfiguration<"line">;
},
[detail, isToday, todayChartData, view],
[detail, isToday, locale, todayChartData, view],
);
return (
@@ -268,7 +263,7 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
</div>
{isToday && (
<div className="chart-legend">
{todayChartData?.legendText || "暂无机场报文或小时级实测数据"}
{todayChartData?.legendText || t("future.chartLegendEmpty")}
</div>
)}
</>
@@ -277,20 +272,23 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
export function FutureForecastModal() {
const store = useDashboardStore();
const { locale, t } = useI18n();
const detail = store.selectedDetail;
const dateStr = store.futureModalDate;
if (!detail || !dateStr) return null;
const isToday = dateStr === detail.local_date;
const view = getFutureModalView(detail, dateStr);
const nowcastRows = getShortTermNowcastLines(detail, dateStr);
const view = getFutureModalView(detail, dateStr, locale);
const nowcastRows = getShortTermNowcastLines(detail, dateStr, locale);
const riskLines = getSettlementRiskNarrative(detail, locale);
const climateDrivers = getClimateDrivers(detail, locale);
const ai = parseAiAnalysis(detail.ai_analysis);
const scorePosition = `${50 + view.front.score / 2}%`;
const barStyle = {
"--score-position": scorePosition,
} as CSSProperties & { "--score-position": string };
const weatherSummary = getWeatherSummary(detail);
const weatherSummary = getWeatherSummary(detail, locale);
return (
<div
@@ -308,13 +306,18 @@ export function FutureForecastModal() {
<div className="modal-header">
<h2 id="future-modal-title">
{isToday
? `${detail.display_name.toUpperCase()} · 今日日内分析`
: `${detail.display_name.toUpperCase()} · ${dateStr} 未来日期分析`}
? t("future.todayTitle", {
city: detail.display_name.toUpperCase(),
})
: t("future.dateTitle", {
city: detail.display_name.toUpperCase(),
date: dateStr,
})}
</h2>
<button
type="button"
className="modal-close"
aria-label={isToday ? "关闭今日日内分析" : "关闭未来日期分析"}
aria-label={isToday ? t("future.closeTodayAria") : t("future.closeDateAria")}
onClick={store.closeFutureModal}
>
×
@@ -326,35 +329,35 @@ export function FutureForecastModal() {
{isToday && (
<>
<div className="h-stat-card">
<span className="label"></span>
<span className="label">{t("future.currentObs")}</span>
<span className="val">
{detail.current?.temp ?? "--"}
{detail.temp_symbol} @{detail.current?.obs_time || "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label"></span>
<span className="label">{t("future.currentWeather")}</span>
<span className="val">
{weatherSummary.weatherIcon} {weatherSummary.weatherText}
</span>
</div>
<div className="h-stat-card">
<span className="label">WU </span>
<span className="label">{t("future.wuRef")}</span>
<span className="val">
{detail.current?.wu_settlement ?? "--"}
{detail.temp_symbol}
</span>
</div>
<div className="h-stat-card">
<span className="label"></span>
<span className="label">{t("future.sunrise")}</span>
<span className="val">{detail.forecast?.sunrise || "--"}</span>
</div>
<div className="h-stat-card">
<span className="label"></span>
<span className="label">{t("future.sunset")}</span>
<span className="val">{detail.forecast?.sunset || "--"}</span>
</div>
<div className="h-stat-card">
<span className="label"></span>
<span className="label">{t("future.sunshine")}</span>
<span className="val">
{detail.forecast?.sunshine_hours != null
? `${detail.forecast.sunshine_hours}h`
@@ -365,27 +368,29 @@ export function FutureForecastModal() {
)}
<div className="h-stat-card">
<span className="label">{isToday ? "今日预报高温" : "目标日预报"}</span>
<span className="label">
{isToday ? t("future.todayForecastHigh") : t("future.targetForecast")}
</span>
<span className="val">
{view.forecastEntry?.max_temp ?? "--"}
{detail.temp_symbol}
</span>
</div>
<div className="h-stat-card">
<span className="label">DEB </span>
<span className="label">{t("future.deb")}</span>
<span className="val">
{view.deb ?? "--"}
{detail.temp_symbol}
</span>
</div>
<div className="h-stat-card">
<span className="label"></span>
<span className="label">{t("future.mu")}</span>
<span className="val">
{view.mu != null ? `${view.mu.toFixed(1)}${detail.temp_symbol}` : "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label"></span>
<span className="label">{t("future.score")}</span>
<span className="val">
{view.front.score > 0 ? "+" : ""}
{view.front.score}
@@ -394,17 +399,17 @@ export function FutureForecastModal() {
</div>
<section className="future-modal-section">
<h3>{isToday ? "今日温度走势" : "目标日小时走势"}</h3>
<h3>{isToday ? t("future.todayTempTrend") : t("future.targetTempTrend")}</h3>
<DailyTemperatureChart dateStr={dateStr} />
</section>
<div className="future-modal-grid">
<section className="future-modal-section">
<h3></h3>
<h3>{t("future.probability")}</h3>
<ProbabilityDistribution detail={detail} targetDate={dateStr} hideTitle />
</section>
<section className="future-modal-section">
<h3></h3>
<h3>{t("future.models")}</h3>
<ModelForecast detail={detail} targetDate={dateStr} hideTitle />
</section>
</div>
@@ -427,17 +432,20 @@ export function FutureForecastModal() {
<path d="M22 19V13" />
</svg>
</span>
{isToday ? "今日日内结构信号" : "未来 6-48 小时趋势"}
{isToday ? t("future.structureToday") : t("future.structureDate")}
</h3>
<div className="future-front-score">
<div className="future-front-bar" style={barStyle} />
<div className="future-front-meta">
<span className="future-front-pill">: {view.front.label}</span>
<span className="future-front-pill">
: {getConfidenceLabel(view.front.confidence)}
{t("future.judgement")}: {view.front.label}
</span>
<span className="future-front-pill">
: {Math.round(view.front.precipMax)}%
{t("future.confidence")}:{" "}
{t(`confidence.${view.front.confidence}`)}
</span>
<span className="future-front-pill">
{t("future.maxPrecip")}: {Math.round(view.front.precipMax)}%
</span>
</div>
<div className="future-text-block">{view.front.summary}</div>
@@ -462,7 +470,7 @@ export function FutureForecastModal() {
</section>
<section className="future-modal-section">
<h3>AI </h3>
<h3>{t("future.ai")}</h3>
<div className="future-text-block">
{ai.summary ? <div>{ai.summary}</div> : null}
@@ -475,7 +483,7 @@ export function FutureForecastModal() {
)}
{!ai.summary && ai.bullets.length === 0 && (
<div> AI </div>
<div>{t("future.noAi")}</div>
)}
<div style={{ marginTop: "14px" }}>
@@ -489,7 +497,7 @@ export function FutureForecastModal() {
{view.front.weatherGovPeriods.length > 0 && (
<div style={{ marginTop: "10px" }}>
<strong>weather.gov : </strong>
<strong>{t("future.weatherGov")}: </strong>
{view.front.weatherGovPeriods
.map((period) => period.short_forecast || period.detailed_forecast)
.filter(Boolean)
@@ -499,6 +507,36 @@ export function FutureForecastModal() {
</div>
</section>
</div>
{isToday && (
<div className="future-modal-grid">
<section className="future-modal-section">
<h3>{t("future.risk")}</h3>
<div className="risk-info">
{riskLines.map((line) => (
<div key={line} className="risk-row">
<span style={{ color: "var(--accent-cyan)", opacity: 0.6 }}>
</span>
<span>{line}</span>
</div>
))}
</div>
</section>
<section className="future-modal-section">
<h3>{t("future.climate")}</h3>
<div className="insight-list">
{climateDrivers.map((driver) => (
<div key={driver.label} className="insight-item">
<div className="insight-title">{driver.label}</div>
<div className="insight-text">{driver.text}</div>
</div>
))}
</div>
</section>
</div>
)}
</div>
</div>
</div>
+61 -34
View File
@@ -1,36 +1,66 @@
"use client";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
const GUIDE_CARDS = [
{
body: "Dynamic Ensemble Blending 是系统的核心预测层。它不是对 ECMWF、GFS、ICON、GEM、JMA 等模型的简单平均,而是结合近期样本表现、当前实况与城市偏置后得到的动态加权结果。",
title: "DEB 动态融合预测",
},
{
body: "右侧的结算概率分布基于 DEB 预测值与多模型离散度动态计算。μ 代表当前分布中心,会随着模型、实况和时间变化而变化,不是固定结算值。",
title: "结算概率引擎",
},
{
body: "Polymarket 结算逻辑以机场 METAR 为主。系统优先使用 Aviation Weather API 的机场报文与原始 METAR,并区分观测时间与接收时间,避免把发布延迟误认为温度变化。",
title: "结算点与主观测源",
},
{
body: "Ankara 不走通用城市逻辑。结算主站以 LTAC / Esenboğa 为准,周边领先信号优先参考 Turkish MGM 站网,其中 Ankara (Bölge/Center) 是重点监控站,不用 Etimesgut 代替。",
title: "Ankara 专属增强",
},
{
body: "点击多日预报后的模态框,主要用于分析下一个交易日。6-48 小时趋势以 weather.gov 和 Open-Meteo 为主,部分城市补充 Meteoblue;0-2 小时临近判断优先看 METAR 与周边站。",
title: "未来日期分析",
},
{
body: "历史准确率对账只统计已结算样本。网页端采用近 15 天滚动视图,不把当天尚未结算的样本算入胜率和 MAE。",
title: "历史对账规则",
},
];
const GUIDE_CARDS = {
"zh-CN": [
{
body: "Dynamic Ensemble Blending 是系统的核心预测层。它不是对 ECMWF、GFS、ICON、GEM、JMA 等模型的简单平均,而是结合近期样本表现、当前实况与城市偏置后得到的动态加权结果。",
title: "DEB 动态融合预测",
},
{
body: "右侧的结算概率分布基于 DEB 预测值与多模型离散度动态计算。μ 代表当前分布中心,会随着模型、实况和时间变化而变化,不是固定结算值。",
title: "结算概率引擎",
},
{
body: "Polymarket 结算逻辑以机场 METAR 为主。系统优先使用 Aviation Weather API 的机场报文与原始 METAR,并区分观测时间与接收时间,避免把发布延迟误认为温度变化。",
title: "结算点与主观测源",
},
{
body: "Ankara 不走通用城市逻辑。结算主站以 LTAC / Esenboğa 为准,周边领先信号优先参考 Turkish MGM 站网,其中 Ankara (Bölge/Center) 是重点监控站,不用 Etimesgut 代替。",
title: "Ankara 专属增强",
},
{
body: "点击多日预报后的模态框,主要用于分析下一个交易日。6-48 小时趋势以 weather.gov 和 Open-Meteo 为主,部分城市补充 Meteoblue;0-2 小时临近判断优先看 METAR 与周边站。",
title: "未来日期分析",
},
{
body: "历史准确率对账只统计已结算样本。网页端采用近 15 天滚动视图,不把当天尚未结算的样本算入胜率和 MAE。",
title: "历史对账规则",
},
],
"en-US": [
{
body: "Dynamic Ensemble Blending (DEB) is the core prediction layer. It is not a simple average across ECMWF/GFS/ICON/GEM/JMA, but a dynamically weighted blend adjusted by recent model performance, current observations, and city bias.",
title: "DEB Dynamic Fusion",
},
{
body: "Settlement probability distribution is dynamically computed from DEB forecast and model spread. μ is the current distribution center and shifts with model updates, observations, and time.",
title: "Settlement Probability Engine",
},
{
body: "Polymarket settlement follows airport METAR observations. The system prioritizes Aviation Weather API raw METAR and distinguishes observation time from receipt time to avoid misreading publication delay as temperature change.",
title: "Settlement Source Logic",
},
{
body: "Ankara does not follow the generic city path. LTAC / Esenboğa is the settlement station, with Turkish MGM network for leading signals. Ankara (Bölge/Center) is a key station and is not replaced by Etimesgut.",
title: "Ankara-specific Enhancement",
},
{
body: "The multi-day modal focuses on next-session analysis. 6-48h trend mainly relies on weather.gov and Open-Meteo, with Meteoblue in selected cities; 0-2h nowcast prioritizes METAR and nearby stations.",
title: "Future-date Analysis",
},
{
body: "History reconciliation only uses settled samples. The web dashboard uses a rolling 15-day window and excludes same-day unsettled samples from hit-rate and MAE.",
title: "History Rules",
},
],
} as const;
export function GuideModal() {
const store = useDashboardStore();
const { locale, t } = useI18n();
if (!store.isGuideOpen) return null;
@@ -48,29 +78,26 @@ export function GuideModal() {
>
<div className="modal-content large">
<div className="modal-header">
<h2 id="guide-modal-title">📎 PolyWeather </h2>
<h2 id="guide-modal-title">{t("guide.title")}</h2>
<button
type="button"
className="modal-close"
aria-label="关闭技术说明"
aria-label={t("guide.closeAria")}
onClick={store.closeGuide}
>
×
</button>
</div>
<div className="modal-body">
<div className="guide-grid">
{GUIDE_CARDS.map((card) => (
{GUIDE_CARDS[locale].map((card) => (
<div key={card.title} className="guide-card">
<h3>{card.title}</h3>
<p>{card.body}</p>
</div>
))}
</div>
<div className="guide-footer">
Aviation Weather / METARTurkish MGMOpen-Meteoweather.gov
Meteoblue
</div>
<div className="guide-footer">{t("guide.footer")}</div>
</div>
</div>
</div>
+46 -22
View File
@@ -2,38 +2,62 @@
import clsx from "clsx";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
export function HeaderBar() {
const store = useDashboardStore();
const { locale, setLocale, t } = useI18n();
return (
<header className="header">
<div className="brand">
<h1>PolyWeather</h1>
<span className="subtitle"></span>
<span className="subtitle">{t("header.subtitle")}</span>
</div>
<button
type="button"
className="info-btn"
title="查看系统技术说明"
aria-label="查看系统技术说明"
onClick={store.openGuide}
>
</button>
<div className="live-badge" id="liveBadge">
<span className="pulse-dot" />
<span></span>
<div className="header-right">
<div className="lang-switch" role="group" aria-label={t("header.langAria")}>
<button
type="button"
className={clsx("lang-btn", locale === "zh-CN" && "active")}
onClick={() => setLocale("zh-CN")}
>
{t("header.langZh")}
</button>
<button
type="button"
className={clsx("lang-btn", locale === "en-US" && "active")}
onClick={() => setLocale("en-US")}
>
{t("header.langEn")}
</button>
</div>
<button
type="button"
className="info-btn"
title={t("header.infoAria")}
aria-label={t("header.infoAria")}
onClick={store.openGuide}
>
{t("header.info")}
</button>
<div className="live-badge" id="liveBadge">
<span className="pulse-dot" />
<span>{t("header.live")}</span>
</div>
<button
type="button"
className={clsx("refresh-btn", store.loadingState.refresh && "spinning")}
title={t("header.refreshAria")}
aria-label={t("header.refreshAria")}
onClick={() => void store.refreshAll()}
>
</button>
</div>
<button
type="button"
className={clsx("refresh-btn", store.loadingState.refresh && "spinning")}
title="刷新所有数据"
aria-label="刷新所有数据"
onClick={() => void store.refreshAll()}
>
</button>
</header>
);
}
+42 -21
View File
@@ -4,10 +4,12 @@ import { ChartConfiguration } from "chart.js/auto";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getHistorySummary } from "@/lib/dashboard-utils";
function HistoryChart() {
const store = useDashboardStore();
const { locale } = useI18n();
const { data } = useHistoryData();
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
@@ -25,10 +27,11 @@ function HistoryChart() {
borderColor: "#f87171",
borderWidth: 2,
data: summary.actuals,
label: "实测最高温",
label: locale === "en-US" ? "Observed High" : "实测最高温",
pointBackgroundColor: "#f87171",
pointBorderColor: "#fff",
pointRadius: 4,
pointHoverRadius: 7,
pointRadius: 5,
tension: 0.2,
},
{
@@ -37,8 +40,9 @@ function HistoryChart() {
borderDash: [5, 4],
borderWidth: 2,
data: summary.debs,
label: "DEB 融合",
pointRadius: 3,
label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
},
];
@@ -49,8 +53,9 @@ function HistoryChart() {
borderColor: "#fb923c",
borderWidth: 2,
data: summary.mgms,
label: "MGM 官方预报",
pointRadius: 3,
label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
@@ -66,14 +71,19 @@ function HistoryChart() {
plugins: {
legend: {
labels: {
boxHeight: 12,
boxWidth: 34,
color: "#94a3b8",
font: { family: "Inter", size: 12 },
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)}°`,
},
@@ -83,18 +93,26 @@ function HistoryChart() {
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: { color: "#64748b", font: { family: "Inter", size: 10 } },
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: 10 } },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
},
[hasMgm, summary],
[hasMgm, summary, locale],
);
if (!summary.recentData.length) return null;
@@ -108,6 +126,7 @@ function HistoryChart() {
export function HistoryModal() {
const store = useDashboardStore();
const { t } = useI18n();
const { data, error, isLoading, isOpen } = useHistoryData();
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
@@ -128,45 +147,47 @@ export function HistoryModal() {
}
}}
>
<div className="modal-content">
<div className="modal-content history-modal">
<div className="modal-header">
<h2 id="history-modal-title">
📊 - {store.selectedCity?.toUpperCase()}
{t("history.title", { city: store.selectedCity?.toUpperCase() || "" })}
</h2>
<button
type="button"
className="modal-close"
aria-label="关闭历史对账"
aria-label={t("history.closeAria")}
onClick={store.closeHistory}
>
×
</button>
</div>
<div className="modal-body">
<div className="history-stats">
{isLoading ? (
<span style={{ color: "var(--text-muted)" }}>...</span>
<span style={{ color: "var(--text-muted)" }}>{t("history.loading")}</span>
) : error ? (
<span style={{ color: "var(--accent-red)" }}></span>
<span style={{ color: "var(--accent-red)" }}>{t("history.error")}</span>
) : !summary.recentData.length ? (
<span style={{ color: "var(--text-muted)" }}> 15 </span>
<span style={{ color: "var(--text-muted)" }}>{t("history.empty")}</span>
) : (
<>
<div className="h-stat-card">
<span className="label">DEB (WU)</span>
<span className="label">{t("history.hitRate")}</span>
<span className="val">
{summary.hitRate != null ? `${summary.hitRate}%` : "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">DEB MAE</span>
<span className="label">{t("history.mae")}</span>
<span className="val">
{summary.debMae != null ? `${summary.debMae}°` : "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label"> 15 </span>
<span className="val">{summary.settledCount} </span>
<span className="label">{t("history.sample")}</span>
<span className="val">
{t("history.sampleDays", { count: summary.settledCount })}
</span>
</div>
</>
)}
+47 -34
View File
@@ -4,11 +4,13 @@ import { ChartConfiguration } from "chart.js/auto";
import clsx from "clsx";
import { useChart } from "@/hooks/useChart";
import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { CityDetail } from "@/lib/dashboard-types";
import {
getHeroMetaItems,
getModelView,
getProbabilityView,
getRiskBadgeLabel,
getTemperatureChartData,
getWeatherSummary,
parseAiAnalysis,
@@ -20,10 +22,11 @@ function EmptyState({ text }: { text: string }) {
export function HeroSummary() {
const { data } = useCityData();
const { locale } = useI18n();
if (!data) return null;
const { weatherIcon, weatherText } = getWeatherSummary(data);
const metaItems = getHeroMetaItems(data);
const { weatherIcon, weatherText } = getWeatherSummary(data, locale);
const metaItems = getHeroMetaItems(data, locale);
const current = data.current || {};
const isMax =
current.max_so_far != null &&
@@ -45,12 +48,14 @@ export function HeroSummary() {
</div>
<div className="hero-max-time">
{isMax && current.max_temp_time
? `该城市今日最高温出现在当地时间 ${current.max_temp_time}`
? locale === "en-US"
? `Today's peak temperature appeared at local time ${current.max_temp_time}`
: `该城市今日最高温出现在当地时间 ${current.max_temp_time}`
: ""}
</div>
<div className="hero-details">
<div className="hero-item">
<span className="label"></span>
<span className="label">{locale === "en-US" ? "Current Obs" : "当前实测"}</span>
<span className="value">
{current.temp != null
? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}`
@@ -58,7 +63,9 @@ export function HeroSummary() {
</span>
</div>
<div className="hero-item">
<span className="label">WU </span>
<span className="label">
{locale === "en-US" ? "WU Settlement Ref" : "WU 结算参考"}
</span>
<span className="value highlight">
{current.wu_settlement != null
? `${current.wu_settlement}${data.temp_symbol}`
@@ -66,7 +73,7 @@ export function HeroSummary() {
</span>
</div>
<div className="hero-item">
<span className="label">DEB </span>
<span className="label">{locale === "en-US" ? "DEB Forecast" : "DEB 预测"}</span>
<span className="value">
{data.deb?.prediction != null
? `${data.deb.prediction}${data.temp_symbol}`
@@ -85,7 +92,8 @@ export function HeroSummary() {
export function TemperatureChart() {
const { data } = useCityData();
const chartData = data ? getTemperatureChartData(data) : null;
const { locale, t } = useI18n();
const chartData = data ? getTemperatureChartData(data, locale) : null;
const canvasRef = useChart(
() => {
@@ -105,7 +113,7 @@ export function TemperatureChart() {
borderWidth: 2,
data: chartData.datasets.mgmHourlyPoints,
fill: false,
label: "MGM 预报",
label: locale === "en-US" ? "MGM Forecast" : "MGM 预报",
pointHoverRadius: 6,
pointRadius: 3,
spanGaps: true,
@@ -118,7 +126,7 @@ export function TemperatureChart() {
borderWidth: 1.5,
data: chartData.datasets.debPast,
fill: true,
label: "DEB 预报",
label: locale === "en-US" ? "DEB Forecast" : "DEB 预报",
pointHoverRadius: 3,
pointRadius: 0,
tension: 0.3,
@@ -129,7 +137,7 @@ export function TemperatureChart() {
borderWidth: 1.5,
data: chartData.datasets.debFuture,
fill: false,
label: "DEB 预报",
label: locale === "en-US" ? "DEB Forecast" : "DEB 预报",
pointRadius: 0,
tension: 0.3,
});
@@ -141,7 +149,7 @@ export function TemperatureChart() {
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: "METAR 实测",
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
order: 0,
pointHoverRadius: 7,
pointRadius: 5,
@@ -154,7 +162,7 @@ export function TemperatureChart() {
borderWidth: 0,
data: chartData.datasets.mgmPoints,
fill: false,
label: "MGM 实测",
label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
order: -1,
pointHoverRadius: 9,
pointRadius: 7,
@@ -172,7 +180,7 @@ export function TemperatureChart() {
borderWidth: 1,
data: chartData.datasets.temps,
fill: false,
label: "OM 原始",
label: locale === "en-US" ? "OM Raw" : "OM 原始",
pointRadius: 0,
tension: 0.3,
});
@@ -221,16 +229,16 @@ export function TemperatureChart() {
type: "line",
} satisfies ChartConfiguration<"line">;
},
[data, chartData],
[data, chartData, locale],
);
return (
<section className="chart-section">
<h3></h3>
<h3>{t("section.todayTempTrend")}</h3>
<div className="chart-wrapper">
<canvas ref={canvasRef} />
</div>
<div className="chart-legend">{chartData?.legendText || "暂无小时级数据"}</div>
<div className="chart-legend">{chartData?.legendText || t("section.chartEmpty")}</div>
</section>
);
}
@@ -244,22 +252,25 @@ export function ProbabilityDistribution({
hideTitle?: boolean;
targetDate?: string | null;
}) {
const { t } = useI18n();
const view = getProbabilityView(detail, targetDate);
return (
<section className="prob-section">
{!hideTitle && <h3></h3>}
{!hideTitle && <h3>{t("section.probability")}</h3>}
<div className="prob-bars">
{view.mu != null && (
<div
style={{ color: "var(--text-muted)", fontSize: "11px", marginBottom: "6px" }}
>
μ = {view.mu.toFixed(1)}
{detail.temp_symbol}
{t("section.mu", {
unit: detail.temp_symbol || "",
value: view.mu.toFixed(1),
})}
</div>
)}
{view.probabilities.length === 0 ? (
<EmptyState text="暂无概率数据" />
<EmptyState text={t("section.noProb")} />
) : (
view.probabilities.slice(0, 6).map((bucket, index) => {
const probability = Math.round(Number(bucket.probability || 0) * 100);
@@ -294,6 +305,7 @@ export function ModelForecast({
hideTitle?: boolean;
targetDate?: string | null;
}) {
const { t } = useI18n();
const view = getModelView(detail, targetDate);
const modelEntries = Object.entries(view.models).filter(([, value]) =>
Number.isFinite(Number(value)),
@@ -307,10 +319,10 @@ export function ModelForecast({
return (
<section className="models-section">
{!hideTitle && <h3></h3>}
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
{!modelEntries.length ? (
<EmptyState text="暂无多模型预报" />
<EmptyState text={t("section.noModels")} />
) : (
<>
{modelEntries
@@ -378,15 +390,16 @@ export function ModelForecast({
export function ForecastTable() {
const store = useDashboardStore();
const { data } = useCityData();
const { t } = useI18n();
if (!data) return null;
const daily = data.forecast?.daily || [];
return (
<section className="forecast-section">
<h3></h3>
<h3>{t("forecast.title")}</h3>
<div className="forecast-table">
{daily.length === 0 ? (
<EmptyState text="暂无多日预报" />
<EmptyState text={t("forecast.empty")} />
) : (
daily.map((day, index) => {
const isToday = day.date === data.local_date || index === 0;
@@ -403,7 +416,7 @@ export function ForecastTable() {
}}
>
<div className="f-date">
{isToday ? "今天" : day.date.substring(5).replace("-", "/")}
{isToday ? t("forecast.today") : day.date.substring(5).replace("-", "/")}
</div>
<div className="f-temp">
{day.max_temp}
@@ -420,17 +433,16 @@ export function ForecastTable() {
export function AiAnalysis() {
const { data } = useCityData();
const { t } = useI18n();
if (!data) return null;
const ai = parseAiAnalysis(data.ai_analysis);
return (
<section className="ai-section">
<h3>AI </h3>
<h3>{t("section.ai")}</h3>
<div className="ai-box">
{!ai.summary && ai.bullets.length === 0 ? (
<span className="ai-placeholder">
AI
</span>
<span className="ai-placeholder">{t("section.aiEmpty")}</span>
) : (
<>
{ai.summary && <div className="ai-summary">{ai.summary}</div>}
@@ -450,30 +462,31 @@ export function AiAnalysis() {
export function RiskInfo() {
const { data } = useCityData();
const { t } = useI18n();
if (!data) return null;
const risk = data.risk || {};
return (
<section className="risk-section">
<h3></h3>
<h3>{t("section.risk")}</h3>
<div className="risk-info">
{!risk.airport ? (
<span style={{ color: "var(--text-muted)" }}></span>
<span style={{ color: "var(--text-muted)" }}>{t("section.noRiskProfile")}</span>
) : (
<>
<div className="risk-row">
<span className="risk-label"></span>
<span className="risk-label">{t("section.airport")}</span>
<span>
{risk.airport} ({risk.icao})
</span>
</div>
<div className="risk-row">
<span className="risk-label"></span>
<span className="risk-label">{t("section.distance")}</span>
<span>{risk.distance_km}km</span>
</div>
{risk.warning && (
<div className="risk-row">
<span className="risk-label"></span>
<span className="risk-label">{t("section.note")}</span>
<span>{risk.warning}</span>
</div>
)}
@@ -6,6 +6,7 @@ import {
DashboardStoreProvider,
useDashboardStore,
} from "@/hooks/useDashboardStore";
import { I18nProvider, useI18n } from "@/hooks/useI18n";
import { CitySidebar } from "@/components/dashboard/CitySidebar";
import { DetailPanel } from "@/components/dashboard/DetailPanel";
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
@@ -16,6 +17,7 @@ import { MapCanvas } from "@/components/dashboard/MapCanvas";
function DashboardScreen() {
const store = useDashboardStore();
const { t } = useI18n();
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -61,7 +63,7 @@ function DashboardScreen() {
{showLoading && (
<div className="loading-overlay">
<div className="loading-spinner" />
<span>...</span>
<span>{t("dashboard.loading")}</span>
</div>
)}
</div>
@@ -70,8 +72,10 @@ function DashboardScreen() {
export function PolyWeatherDashboard() {
return (
<DashboardStoreProvider>
<DashboardScreen />
</DashboardStoreProvider>
<I18nProvider>
<DashboardStoreProvider>
<DashboardScreen />
</DashboardStoreProvider>
</I18nProvider>
);
}