feat: Implement initial weather dashboard with interactive map, city details, and API integration.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
|
||||
export function CitySidebar() {
|
||||
const store = useDashboardStore();
|
||||
const sortedCities = [...store.cities].sort((a, b) => {
|
||||
const order = { high: 0, medium: 1, low: 2 };
|
||||
return (
|
||||
(order[a.risk_level as keyof typeof order] ?? 3) -
|
||||
(order[b.risk_level as keyof typeof order] ?? 3)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<nav className="city-list">
|
||||
<div className="city-list-header">
|
||||
<span>监控城市</span>
|
||||
<span className="city-count">{store.cities.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="city-list-items">
|
||||
{sortedCities.map((city) => {
|
||||
const detail = store.cityDetailsByName[city.name];
|
||||
const summary = store.citySummariesByName[city.name];
|
||||
const snapshot = detail || summary;
|
||||
const isActive = store.selectedCity === city.name;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={city.name}
|
||||
type="button"
|
||||
className={clsx("city-item", isActive && "active")}
|
||||
onClick={() => void store.selectCity(city.name)}
|
||||
>
|
||||
<div className="city-item-main">
|
||||
<span className={clsx("risk-dot", city.risk_level)} />
|
||||
<span className="city-name-text">{city.display_name}</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"city-temp",
|
||||
snapshot?.current?.temp != null && "loaded",
|
||||
)}
|
||||
>
|
||||
{snapshot?.current?.temp != null
|
||||
? `${snapshot.current.temp}${snapshot.temp_symbol || "°C"}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="city-item-info">
|
||||
<span className="city-local-time">
|
||||
{snapshot?.local_time ? `🕐 ${snapshot.local_time}` : ""}
|
||||
</span>
|
||||
<span className="city-max-info">
|
||||
{detail?.current?.max_temp_time
|
||||
? `峰值 @ ${detail.current.max_temp_time}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const PolyWeatherDashboard = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/PolyWeatherDashboard").then(
|
||||
(module) => module.PolyWeatherDashboard,
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export function DashboardEntry() {
|
||||
return <PolyWeatherDashboard />;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { getCityScenery } from "@/lib/dashboard-scenery";
|
||||
import {
|
||||
getCityProfileStats,
|
||||
getClimateDrivers,
|
||||
getRiskBadgeLabel,
|
||||
getSettlementRiskNarrative,
|
||||
} from "@/lib/dashboard-utils";
|
||||
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
||||
|
||||
export function DetailPanel() {
|
||||
const store = useDashboardStore();
|
||||
const detail = store.selectedDetail;
|
||||
const isOverlayOpen =
|
||||
Boolean(store.futureModalDate) ||
|
||||
store.historyState.isOpen ||
|
||||
store.isGuideOpen;
|
||||
const isVisible =
|
||||
store.isPanelOpen &&
|
||||
Boolean(store.selectedCity) &&
|
||||
Boolean(detail) &&
|
||||
!store.loadingState.cityDetail &&
|
||||
!isOverlayOpen;
|
||||
const profileStats = detail ? getCityProfileStats(detail) : [];
|
||||
const riskLines = detail ? getSettlementRiskNarrative(detail) : [];
|
||||
const climateDrivers = detail ? getClimateDrivers(detail) : [];
|
||||
const scenery = getCityScenery(detail?.name);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={clsx("detail-panel", isVisible && "visible")}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
<div className="panel-header">
|
||||
<button
|
||||
type="button"
|
||||
className="panel-close"
|
||||
aria-label="关闭城市详情面板"
|
||||
onClick={store.closePanel}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="panel-title-area">
|
||||
<h2>{detail?.display_name?.toUpperCase() || "—"}</h2>
|
||||
<div className="panel-meta">
|
||||
<span className={clsx("risk-badge", detail?.risk?.level || "low")}>
|
||||
{getRiskBadgeLabel(detail?.risk?.level)}
|
||||
</span>
|
||||
<span className="local-time">
|
||||
{detail
|
||||
? `${detail.local_date} ${detail.local_time}`
|
||||
: "等待选择城市"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="history-btn"
|
||||
title="查看今日日内分析"
|
||||
onClick={store.openTodayModal}
|
||||
disabled={!detail}
|
||||
>
|
||||
今日日内分析
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="history-btn"
|
||||
title="查看历史对账"
|
||||
onClick={() => void store.openHistory()}
|
||||
disabled={!detail}
|
||||
>
|
||||
历史对账
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-body">
|
||||
{!detail ? (
|
||||
<section>
|
||||
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
|
||||
{store.loadingState.cityDetail
|
||||
? "正在加载城市详情..."
|
||||
: "从左侧城市列表选择一个城市查看详情。"}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="detail-scenery-card">
|
||||
{scenery ? (
|
||||
<>
|
||||
<img
|
||||
className="detail-scenery-image"
|
||||
src={scenery.imageUrl}
|
||||
alt={`${detail.display_name} 风景照`}
|
||||
/>
|
||||
<div className="detail-scenery-overlay">
|
||||
<div className="detail-scenery-copy">
|
||||
<span className="detail-scenery-kicker">
|
||||
{detail.display_name}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
className="detail-scenery-credit"
|
||||
href={scenery.creditUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{scenery.creditLabel}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="detail-scenery-fallback">
|
||||
<span className="detail-scenery-kicker">
|
||||
{detail.display_name}
|
||||
</span>
|
||||
<strong className="detail-scenery-title">
|
||||
城市风景与微气候
|
||||
</strong>
|
||||
<span className="detail-scenery-subtitle">
|
||||
当前没有匹配到风景图,仍可从下方档案与风险说明查看城市特征。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="detail-section">
|
||||
<h3>城市档案</h3>
|
||||
<div className="detail-grid">
|
||||
{profileStats.map((item) => (
|
||||
<div key={item.label} className="detail-card">
|
||||
<span className="detail-label">{item.label}</span>
|
||||
<span className="detail-value">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
</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 />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
"use client";
|
||||
|
||||
import { ChartConfiguration } from "chart.js/auto";
|
||||
import clsx from "clsx";
|
||||
import { CSSProperties } from "react";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import {
|
||||
ModelForecast,
|
||||
ProbabilityDistribution,
|
||||
} from "@/components/dashboard/PanelSections";
|
||||
import {
|
||||
getFutureModalView,
|
||||
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 detail = store.selectedDetail;
|
||||
const view = detail ? getFutureModalView(detail, dateStr) : null;
|
||||
const isToday = detail ? dateStr === detail.local_date : false;
|
||||
const todayChartData = detail && isToday ? getTemperatureChartData(detail) : null;
|
||||
|
||||
const canvasRef = useChart(
|
||||
() => {
|
||||
if (!detail || !view) {
|
||||
return {
|
||||
data: { datasets: [], labels: [] },
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
if (isToday && todayChartData) {
|
||||
const datasets: NonNullable<ChartConfiguration<"line">["data"]>["datasets"] = [];
|
||||
|
||||
if (todayChartData.datasets.hasMgmHourly) {
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(234, 179, 8, 0.05)",
|
||||
borderColor: "rgba(234, 179, 8, 0.8)",
|
||||
borderWidth: 2,
|
||||
data: todayChartData.datasets.mgmHourlyPoints,
|
||||
fill: false,
|
||||
label: "MGM 预报",
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 3,
|
||||
spanGaps: true,
|
||||
tension: 0.3,
|
||||
});
|
||||
} else {
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(52, 211, 153, 0.05)",
|
||||
borderColor: "rgba(52, 211, 153, 0.6)",
|
||||
borderWidth: 1.5,
|
||||
data: todayChartData.datasets.debPast,
|
||||
fill: true,
|
||||
label: "DEB 预报",
|
||||
pointHoverRadius: 3,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
datasets.push({
|
||||
borderColor: "rgba(52, 211, 153, 0.35)",
|
||||
borderDash: [5, 3],
|
||||
borderWidth: 1.5,
|
||||
data: todayChartData.datasets.debFuture,
|
||||
fill: false,
|
||||
label: "DEB 预报",
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
datasets.push({
|
||||
backgroundColor: "#22d3ee",
|
||||
borderColor: "#22d3ee",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.metarPoints,
|
||||
fill: false,
|
||||
label: "METAR 实测",
|
||||
order: 0,
|
||||
pointHoverRadius: 7,
|
||||
pointRadius: 5,
|
||||
});
|
||||
|
||||
if (todayChartData.datasets.mgmPoints.some((value) => value != null)) {
|
||||
datasets.push({
|
||||
backgroundColor: "#facc15",
|
||||
borderColor: "#facc15",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.mgmPoints,
|
||||
fill: false,
|
||||
label: "MGM 实测",
|
||||
order: -1,
|
||||
pointHoverRadius: 9,
|
||||
pointRadius: 7,
|
||||
showLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!todayChartData.datasets.hasMgmHourly &&
|
||||
Math.abs(todayChartData.datasets.offset) > 0.3
|
||||
) {
|
||||
datasets.push({
|
||||
borderColor: "rgba(99, 102, 241, 0.2)",
|
||||
borderDash: [2, 4],
|
||||
borderWidth: 1,
|
||||
data: todayChartData.datasets.temps,
|
||||
fill: false,
|
||||
label: "OM 原始",
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets,
|
||||
labels: todayChartData.times,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: "#94a3b8",
|
||||
filter: (legendItem, chartData) => {
|
||||
const text = String(legendItem.text || "");
|
||||
if (!text) return false;
|
||||
if (!text.includes("DEB")) return true;
|
||||
|
||||
const firstDebIndex = (chartData.datasets || []).findIndex((dataset) =>
|
||||
String(dataset.label || "").includes("DEB"),
|
||||
);
|
||||
return legendItem.datasetIndex === firstDebIndex;
|
||||
},
|
||||
font: { family: "Inter", size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||||
borderColor: "rgba(34, 211, 238, 0.2)",
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
callback: (_value, index) =>
|
||||
typeof index === "number" && index % 3 === 0
|
||||
? todayChartData.times[index]
|
||||
: "",
|
||||
color: "#64748b",
|
||||
font: { family: "Inter", size: 10 },
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
max: todayChartData.max,
|
||||
min: todayChartData.min,
|
||||
ticks: {
|
||||
callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
|
||||
color: "#64748b",
|
||||
font: { family: "Inter", size: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
const labels = view.slice.map((point) => point.label);
|
||||
const unit = detail.temp_symbol || "°C";
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor: "rgba(34, 211, 238, 0.08)",
|
||||
borderColor: "#22d3ee",
|
||||
data: view.slice.map((point) => point.temp),
|
||||
fill: false,
|
||||
label: "Open-Meteo 温度",
|
||||
pointRadius: 2,
|
||||
tension: 0.28,
|
||||
},
|
||||
{
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#a78bfa",
|
||||
borderDash: [5, 4],
|
||||
data: view.slice.map((point) => point.dewPoint),
|
||||
fill: false,
|
||||
label: "露点",
|
||||
pointRadius: 0,
|
||||
tension: 0.24,
|
||||
},
|
||||
],
|
||||
labels,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: "#94a3b8",
|
||||
font: { family: "Inter", size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||||
borderColor: "rgba(34, 211, 238, 0.2)",
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: (ctx) =>
|
||||
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${unit}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
color: "#64748b",
|
||||
font: { family: "Inter", size: 10 },
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
callback: (value) => `${value}${unit}`,
|
||||
color: "#64748b",
|
||||
font: { family: "Inter", size: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
},
|
||||
[detail, isToday, todayChartData, view],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="history-chart-wrapper future-chart-wrapper">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
{isToday && (
|
||||
<div className="chart-legend">
|
||||
{todayChartData?.legendText || "暂无机场报文或小时级实测数据"}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FutureForecastModal() {
|
||||
const store = useDashboardStore();
|
||||
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 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);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="future-modal-title"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
store.closeFutureModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="modal-content large future-modal">
|
||||
<div className="modal-header">
|
||||
<h2 id="future-modal-title">
|
||||
{isToday
|
||||
? `${detail.display_name.toUpperCase()} · 今日日内分析`
|
||||
: `${detail.display_name.toUpperCase()} · ${dateStr} 未来日期分析`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
aria-label={isToday ? "关闭今日日内分析" : "关闭未来日期分析"}
|
||||
onClick={store.closeFutureModal}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body future-modal-body">
|
||||
<div className="history-stats">
|
||||
{isToday && (
|
||||
<>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">当前实测</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="val">
|
||||
{weatherSummary.weatherIcon} {weatherSummary.weatherText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">WU 结算参考</span>
|
||||
<span className="val">
|
||||
{detail.current?.wu_settlement ?? "--"}
|
||||
{detail.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">日出时间</span>
|
||||
<span className="val">{detail.forecast?.sunrise || "--"}</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">日落时间</span>
|
||||
<span className="val">{detail.forecast?.sunset || "--"}</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">日照时长</span>
|
||||
<span className="val">
|
||||
{detail.forecast?.sunshine_hours != null
|
||||
? `${detail.forecast.sunshine_hours}h`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="h-stat-card">
|
||||
<span className="label">{isToday ? "今日预报高温" : "目标日预报"}</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="val">
|
||||
{view.deb ?? "--"}
|
||||
{detail.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">动态分布中心</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="val">
|
||||
{view.front.score > 0 ? "+" : ""}
|
||||
{view.front.score}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="future-modal-section">
|
||||
<h3>{isToday ? "今日温度走势" : "目标日小时走势"}</h3>
|
||||
<DailyTemperatureChart dateStr={dateStr} />
|
||||
</section>
|
||||
|
||||
<div className="future-modal-grid">
|
||||
<section className="future-modal-section">
|
||||
<h3>结算概率分布</h3>
|
||||
<ProbabilityDistribution detail={detail} targetDate={dateStr} hideTitle />
|
||||
</section>
|
||||
<section className="future-modal-section">
|
||||
<h3>多模型预报</h3>
|
||||
<ModelForecast detail={detail} targetDate={dateStr} hideTitle />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="future-modal-grid">
|
||||
<section className="future-modal-section">
|
||||
<h3>
|
||||
<span className="section-inline-icon" aria-hidden="true">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.9"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 19V5" />
|
||||
<path d="M10 19V10" />
|
||||
<path d="M16 19V7" />
|
||||
<path d="M22 19V13" />
|
||||
</svg>
|
||||
</span>
|
||||
{isToday ? "今日日内结构信号" : "未来 6-48 小时趋势"}
|
||||
</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)}
|
||||
</span>
|
||||
<span className="future-front-pill">
|
||||
最大降水概率: {Math.round(view.front.precipMax)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="future-text-block">{view.front.summary}</div>
|
||||
</div>
|
||||
<div className="future-trend-grid">
|
||||
{view.front.metrics.map((metric) => (
|
||||
<div key={metric.label} className="future-trend-card">
|
||||
<div className="future-trend-label">{metric.label}</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-value",
|
||||
metric.tone === "warm" && "warm",
|
||||
metric.tone === "cold" && "cold",
|
||||
)}
|
||||
>
|
||||
{metric.value}
|
||||
</div>
|
||||
<div className="future-trend-note">{metric.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="future-modal-section">
|
||||
<h3>AI 深度分析</h3>
|
||||
<div className="future-text-block">
|
||||
{ai.summary ? <div>{ai.summary}</div> : null}
|
||||
|
||||
{ai.bullets.length > 0 && (
|
||||
<div style={{ marginTop: ai.summary ? "10px" : 0 }}>
|
||||
{ai.bullets.map((item) => (
|
||||
<div key={item}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!ai.summary && ai.bullets.length === 0 && (
|
||||
<div>暂无 AI 分析,当前以结构化气象与模型数据为主。</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: "14px" }}>
|
||||
{nowcastRows.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<strong>{label}: </strong>
|
||||
{value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{view.front.weatherGovPeriods.length > 0 && (
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<strong>weather.gov 文本: </strong>
|
||||
{view.front.weatherGovPeriods
|
||||
.map((period) => period.short_forecast || period.detailed_forecast)
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
|
||||
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: "历史对账规则",
|
||||
},
|
||||
];
|
||||
|
||||
export function GuideModal() {
|
||||
const store = useDashboardStore();
|
||||
|
||||
if (!store.isGuideOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="guide-modal-title"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
store.closeGuide();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="modal-content large">
|
||||
<div className="modal-header">
|
||||
<h2 id="guide-modal-title">📎 PolyWeather 系统技术说明</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
aria-label="关闭技术说明"
|
||||
onClick={store.closeGuide}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="guide-grid">
|
||||
{GUIDE_CARDS.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 / METAR、Turkish MGM、Open-Meteo、weather.gov
|
||||
为主,部分城市补充 Meteoblue。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
|
||||
export function HeaderBar() {
|
||||
const store = useDashboardStore();
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="brand">
|
||||
<h1>PolyWeather</h1>
|
||||
<span className="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>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("refresh-btn", store.loadingState.refresh && "spinning")}
|
||||
title="刷新所有数据"
|
||||
aria-label="刷新所有数据"
|
||||
onClick={() => void store.refreshAll()}
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { ChartConfiguration } from "chart.js/auto";
|
||||
import { useMemo } from "react";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
|
||||
import { getHistorySummary } from "@/lib/dashboard-utils";
|
||||
|
||||
function HistoryChart() {
|
||||
const store = useDashboardStore();
|
||||
const { data } = useHistoryData();
|
||||
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 canvasRef = useChart(
|
||||
() => {
|
||||
const datasets: NonNullable<ChartConfiguration<"line">["data"]>["datasets"] = [
|
||||
{
|
||||
backgroundColor: "rgba(248, 113, 113, 0.1)",
|
||||
borderColor: "#f87171",
|
||||
borderWidth: 2,
|
||||
data: summary.actuals,
|
||||
label: "实测最高温",
|
||||
pointBackgroundColor: "#f87171",
|
||||
pointBorderColor: "#fff",
|
||||
pointRadius: 4,
|
||||
tension: 0.2,
|
||||
},
|
||||
{
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#34d399",
|
||||
borderDash: [5, 4],
|
||||
borderWidth: 2,
|
||||
data: summary.debs,
|
||||
label: "DEB 融合",
|
||||
pointRadius: 3,
|
||||
tension: 0.2,
|
||||
},
|
||||
];
|
||||
|
||||
if (hasMgm) {
|
||||
datasets.push({
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#fb923c",
|
||||
borderWidth: 2,
|
||||
data: summary.mgms,
|
||||
label: "MGM 官方预报",
|
||||
pointRadius: 3,
|
||||
tension: 0.2,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets,
|
||||
labels: summary.dates,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: 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,
|
||||
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: 10 } },
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: { color: "#64748b", font: { family: "Inter", size: 10 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
},
|
||||
[hasMgm, summary],
|
||||
);
|
||||
|
||||
if (!summary.recentData.length) return null;
|
||||
|
||||
return (
|
||||
<div className="history-chart-wrapper">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryModal() {
|
||||
const store = useDashboardStore();
|
||||
const { data, error, isLoading, isOpen } = useHistoryData();
|
||||
const summary = useMemo(
|
||||
() => getHistorySummary(data, store.selectedDetail?.local_date),
|
||||
[data, store.selectedDetail?.local_date],
|
||||
);
|
||||
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h2 id="history-modal-title">
|
||||
📊 历史准确率对账 - {store.selectedCity?.toUpperCase()}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
aria-label="关闭历史对账"
|
||||
onClick={store.closeHistory}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="history-stats">
|
||||
{isLoading ? (
|
||||
<span style={{ color: "var(--text-muted)" }}>正在获取历史数据...</span>
|
||||
) : error ? (
|
||||
<span style={{ color: "var(--accent-red)" }}>获取历史信息失败</span>
|
||||
) : !summary.recentData.length ? (
|
||||
<span style={{ color: "var(--text-muted)" }}>近 15 天暂无该城市历史数据</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">DEB 结算胜率 (WU)</span>
|
||||
<span className="val">
|
||||
{summary.hitRate != null ? `${summary.hitRate}%` : "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">DEB 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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isLoading && !error && <HistoryChart />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useLeafletMap } from "@/hooks/useLeafletMap";
|
||||
|
||||
export function MapCanvas() {
|
||||
const store = useDashboardStore();
|
||||
const { containerRef } = useLeafletMap({
|
||||
cities: store.cities,
|
||||
cityDetailsByName: store.cityDetailsByName,
|
||||
citySummariesByName: store.citySummariesByName,
|
||||
onClosePanel: store.closePanel,
|
||||
onEnsureCityDetail: store.ensureCityDetail,
|
||||
onRegisterStopMotion: store.registerMapStopMotion,
|
||||
onSelectCity: (cityName) => {
|
||||
void store.selectCity(cityName);
|
||||
},
|
||||
selectedCity: store.selectedCity,
|
||||
selectedDetail: store.selectedDetail,
|
||||
suspendMotion:
|
||||
Boolean(store.futureModalDate) ||
|
||||
store.historyState.isOpen ||
|
||||
store.isGuideOpen,
|
||||
isLoadingDetail: store.loadingState.cityDetail,
|
||||
});
|
||||
|
||||
return <div ref={containerRef} className="map" />;
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
"use client";
|
||||
|
||||
import { ChartConfiguration } from "chart.js/auto";
|
||||
import clsx from "clsx";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { CityDetail } from "@/lib/dashboard-types";
|
||||
import {
|
||||
getHeroMetaItems,
|
||||
getModelView,
|
||||
getProbabilityView,
|
||||
getTemperatureChartData,
|
||||
getWeatherSummary,
|
||||
parseAiAnalysis,
|
||||
} from "@/lib/dashboard-utils";
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return <div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>;
|
||||
}
|
||||
|
||||
export function HeroSummary() {
|
||||
const { data } = useCityData();
|
||||
if (!data) return null;
|
||||
|
||||
const { weatherIcon, weatherText } = getWeatherSummary(data);
|
||||
const metaItems = getHeroMetaItems(data);
|
||||
const current = data.current || {};
|
||||
const isMax =
|
||||
current.max_so_far != null &&
|
||||
current.temp != null &&
|
||||
current.max_so_far <= current.temp;
|
||||
|
||||
return (
|
||||
<section className="hero-section">
|
||||
<div className="hero-weather">
|
||||
<span>
|
||||
{weatherIcon} {weatherText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hero-temp">
|
||||
<span className="hero-value">
|
||||
{current.temp != null ? current.temp.toFixed(1) : "--"}
|
||||
</span>
|
||||
<span className="hero-unit">{data.temp_symbol || "°C"}</span>
|
||||
</div>
|
||||
<div className="hero-max-time">
|
||||
{isMax && current.max_temp_time
|
||||
? `该城市今日最高温出现在当地时间 ${current.max_temp_time}`
|
||||
: ""}
|
||||
</div>
|
||||
<div className="hero-details">
|
||||
<div className="hero-item">
|
||||
<span className="label">当前实测</span>
|
||||
<span className="value">
|
||||
{current.temp != null
|
||||
? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hero-item">
|
||||
<span className="label">WU 结算参考</span>
|
||||
<span className="value highlight">
|
||||
{current.wu_settlement != null
|
||||
? `${current.wu_settlement}${data.temp_symbol}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hero-item">
|
||||
<span className="label">DEB 预测</span>
|
||||
<span className="value">
|
||||
{data.deb?.prediction != null
|
||||
? `${data.deb.prediction}${data.temp_symbol}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-sub">
|
||||
{metaItems.map((item) => (
|
||||
<span key={item}>{item}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function TemperatureChart() {
|
||||
const { data } = useCityData();
|
||||
const chartData = data ? getTemperatureChartData(data) : null;
|
||||
|
||||
const canvasRef = useChart(
|
||||
() => {
|
||||
if (!data || !chartData) {
|
||||
return {
|
||||
data: { datasets: [], labels: [] },
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
const datasets: NonNullable<ChartConfiguration<"line">["data"]>["datasets"] = [];
|
||||
|
||||
if (chartData.datasets.hasMgmHourly) {
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(234, 179, 8, 0.05)",
|
||||
borderColor: "rgba(234, 179, 8, 0.8)",
|
||||
borderWidth: 2,
|
||||
data: chartData.datasets.mgmHourlyPoints,
|
||||
fill: false,
|
||||
label: "MGM 预报",
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 3,
|
||||
spanGaps: true,
|
||||
tension: 0.3,
|
||||
});
|
||||
} else {
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(52, 211, 153, 0.05)",
|
||||
borderColor: "rgba(52, 211, 153, 0.6)",
|
||||
borderWidth: 1.5,
|
||||
data: chartData.datasets.debPast,
|
||||
fill: true,
|
||||
label: "DEB 预报",
|
||||
pointHoverRadius: 3,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
datasets.push({
|
||||
borderColor: "rgba(52, 211, 153, 0.35)",
|
||||
borderDash: [5, 3],
|
||||
borderWidth: 1.5,
|
||||
data: chartData.datasets.debFuture,
|
||||
fill: false,
|
||||
label: "DEB 预报",
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
datasets.push({
|
||||
backgroundColor: "#22d3ee",
|
||||
borderColor: "#22d3ee",
|
||||
borderWidth: 0,
|
||||
data: chartData.datasets.metarPoints,
|
||||
fill: false,
|
||||
label: "METAR 实测",
|
||||
order: 0,
|
||||
pointHoverRadius: 7,
|
||||
pointRadius: 5,
|
||||
});
|
||||
|
||||
if (chartData.datasets.mgmPoints.some((value) => value != null)) {
|
||||
datasets.push({
|
||||
backgroundColor: "#facc15",
|
||||
borderColor: "#facc15",
|
||||
borderWidth: 0,
|
||||
data: chartData.datasets.mgmPoints,
|
||||
fill: false,
|
||||
label: "MGM 实测",
|
||||
order: -1,
|
||||
pointHoverRadius: 9,
|
||||
pointRadius: 7,
|
||||
showLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!chartData.datasets.hasMgmHourly &&
|
||||
Math.abs(chartData.datasets.offset) > 0.3
|
||||
) {
|
||||
datasets.push({
|
||||
borderColor: "rgba(99, 102, 241, 0.2)",
|
||||
borderDash: [2, 4],
|
||||
borderWidth: 1,
|
||||
data: chartData.datasets.temps,
|
||||
fill: false,
|
||||
label: "OM 原始",
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets,
|
||||
labels: chartData.times,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.9)",
|
||||
borderColor: "rgba(52, 211, 153, 0.3)",
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
callback: (_value, index) =>
|
||||
typeof index === "number" && index % 3 === 0
|
||||
? chartData.times[index]
|
||||
: "",
|
||||
color: "#64748b",
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
max: chartData.max,
|
||||
min: chartData.min,
|
||||
ticks: {
|
||||
callback: (value) => `${value}${data.temp_symbol || "°C"}`,
|
||||
color: "#64748b",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
},
|
||||
[data, chartData],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="chart-section">
|
||||
<h3>今日温度走势</h3>
|
||||
<div className="chart-wrapper">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
<div className="chart-legend">{chartData?.legendText || "暂无小时级数据"}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProbabilityDistribution({
|
||||
detail,
|
||||
hideTitle = false,
|
||||
targetDate,
|
||||
}: {
|
||||
detail: CityDetail;
|
||||
hideTitle?: boolean;
|
||||
targetDate?: string | null;
|
||||
}) {
|
||||
const view = getProbabilityView(detail, targetDate);
|
||||
|
||||
return (
|
||||
<section className="prob-section">
|
||||
{!hideTitle && <h3>结算概率分布</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}
|
||||
</div>
|
||||
)}
|
||||
{view.probabilities.length === 0 ? (
|
||||
<EmptyState text="暂无概率数据" />
|
||||
) : (
|
||||
view.probabilities.slice(0, 6).map((bucket, index) => {
|
||||
const probability = Math.round(Number(bucket.probability || 0) * 100);
|
||||
return (
|
||||
<div key={`${bucket.label || bucket.value || index}`} className="prob-row">
|
||||
<div className="prob-label">
|
||||
{bucket.label || `${bucket.value}${detail.temp_symbol}`}
|
||||
</div>
|
||||
<div className="prob-bar-track">
|
||||
<div
|
||||
className={clsx("prob-bar-fill", `rank-${index}`)}
|
||||
style={{ width: `${Math.max(probability, 8)}%` }}
|
||||
>
|
||||
{probability}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelForecast({
|
||||
detail,
|
||||
hideTitle = false,
|
||||
targetDate,
|
||||
}: {
|
||||
detail: CityDetail;
|
||||
hideTitle?: boolean;
|
||||
targetDate?: string | null;
|
||||
}) {
|
||||
const view = getModelView(detail, targetDate);
|
||||
const modelEntries = Object.entries(view.models).filter(([, value]) =>
|
||||
Number.isFinite(Number(value)),
|
||||
);
|
||||
const numericValues = modelEntries.map(([, value]) => Number(value));
|
||||
const comparisonValues =
|
||||
view.deb != null ? [...numericValues, Number(view.deb)] : numericValues;
|
||||
const minValue = comparisonValues.length ? Math.min(...comparisonValues) - 1 : 0;
|
||||
const maxValue = comparisonValues.length ? Math.max(...comparisonValues) + 1 : 1;
|
||||
const range = Math.max(maxValue - minValue, 1);
|
||||
|
||||
return (
|
||||
<section className="models-section">
|
||||
{!hideTitle && <h3>多模型预报</h3>}
|
||||
<div className="model-bars">
|
||||
{!modelEntries.length ? (
|
||||
<EmptyState text="暂无多模型预报" />
|
||||
) : (
|
||||
<>
|
||||
{modelEntries
|
||||
.sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0))
|
||||
.map(([name, value]) => {
|
||||
const numeric = Number(value);
|
||||
const width = ((numeric - minValue) / range) * 100;
|
||||
const debLine =
|
||||
view.deb != null
|
||||
? ((Number(view.deb) - minValue) / range) * 100
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={name} className="model-row">
|
||||
<div className="model-name" title={name}>
|
||||
{name}
|
||||
</div>
|
||||
<div className="model-bar-track">
|
||||
<div className="model-bar-fill" style={{ width: `${width}%` }}>
|
||||
{numeric}
|
||||
{detail.temp_symbol}
|
||||
</div>
|
||||
{debLine != null && (
|
||||
<div className="model-deb-line" style={{ left: `${debLine}%` }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{view.deb != null && (
|
||||
<div
|
||||
className="model-row"
|
||||
style={{
|
||||
borderTop: "1px solid rgba(255,255,255,0.06)",
|
||||
marginTop: "6px",
|
||||
paddingTop: "6px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="model-name"
|
||||
style={{ color: "var(--accent-cyan)", fontWeight: 700 }}
|
||||
>
|
||||
DEB
|
||||
</div>
|
||||
<div className="model-bar-track">
|
||||
<div
|
||||
className="model-bar-fill deb"
|
||||
style={{
|
||||
width: `${((Number(view.deb) - minValue) / range) * 100}%`,
|
||||
}}
|
||||
>
|
||||
{Number(view.deb)}
|
||||
{detail.temp_symbol}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForecastTable() {
|
||||
const store = useDashboardStore();
|
||||
const { data } = useCityData();
|
||||
if (!data) return null;
|
||||
|
||||
const daily = data.forecast?.daily || [];
|
||||
return (
|
||||
<section className="forecast-section">
|
||||
<h3>多日预报</h3>
|
||||
<div className="forecast-table">
|
||||
{daily.length === 0 ? (
|
||||
<EmptyState text="暂无多日预报" />
|
||||
) : (
|
||||
daily.map((day, index) => {
|
||||
const isToday = day.date === data.local_date || index === 0;
|
||||
const isSelected =
|
||||
store.futureModalDate === day.date ||
|
||||
store.selectedForecastDate === day.date;
|
||||
return (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
className={clsx("forecast-day", isToday && "today", isSelected && "selected")}
|
||||
onClick={() => {
|
||||
store.openFutureModal(day.date);
|
||||
}}
|
||||
>
|
||||
<div className="f-date">
|
||||
{isToday ? "今天" : day.date.substring(5).replace("-", "/")}
|
||||
</div>
|
||||
<div className="f-temp">
|
||||
{day.max_temp}
|
||||
{data.temp_symbol}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiAnalysis() {
|
||||
const { data } = useCityData();
|
||||
if (!data) return null;
|
||||
const ai = parseAiAnalysis(data.ai_analysis);
|
||||
|
||||
return (
|
||||
<section className="ai-section">
|
||||
<h3>AI 深度分析</h3>
|
||||
<div className="ai-box">
|
||||
{!ai.summary && ai.bullets.length === 0 ? (
|
||||
<span className="ai-placeholder">
|
||||
暂无 AI 分析,当前以结构化气象与模型数据为主。
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{ai.summary && <div className="ai-summary">{ai.summary}</div>}
|
||||
{ai.bullets.length > 0 && (
|
||||
<ul className="ai-list">
|
||||
{ai.bullets.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function RiskInfo() {
|
||||
const { data } = useCityData();
|
||||
if (!data) return null;
|
||||
const risk = data.risk || {};
|
||||
|
||||
return (
|
||||
<section className="risk-section">
|
||||
<h3>数据偏差风险</h3>
|
||||
<div className="risk-info">
|
||||
{!risk.airport ? (
|
||||
<span style={{ color: "var(--text-muted)" }}>暂无风险档案</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="risk-row">
|
||||
<span className="risk-label">机场</span>
|
||||
<span>
|
||||
{risk.airport} ({risk.icao})
|
||||
</span>
|
||||
</div>
|
||||
<div className="risk-row">
|
||||
<span className="risk-label">距离</span>
|
||||
<span>{risk.distance_km}km</span>
|
||||
</div>
|
||||
{risk.warning && (
|
||||
<div className="risk-row">
|
||||
<span className="risk-label">注意</span>
|
||||
<span>{risk.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import styles from "./Dashboard.module.css";
|
||||
import {
|
||||
DashboardStoreProvider,
|
||||
useDashboardStore,
|
||||
} from "@/hooks/useDashboardStore";
|
||||
import { CitySidebar } from "@/components/dashboard/CitySidebar";
|
||||
import { DetailPanel } from "@/components/dashboard/DetailPanel";
|
||||
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
|
||||
import { GuideModal } from "@/components/dashboard/GuideModal";
|
||||
import { HeaderBar } from "@/components/dashboard/HeaderBar";
|
||||
import { HistoryModal } from "@/components/dashboard/HistoryModal";
|
||||
import { MapCanvas } from "@/components/dashboard/MapCanvas";
|
||||
|
||||
function DashboardScreen() {
|
||||
const store = useDashboardStore();
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (store.futureModalDate) {
|
||||
store.closeFutureModal();
|
||||
return;
|
||||
}
|
||||
if (store.historyState.isOpen) {
|
||||
store.closeHistory();
|
||||
return;
|
||||
}
|
||||
if (store.isGuideOpen) {
|
||||
store.closeGuide();
|
||||
return;
|
||||
}
|
||||
if (store.isPanelOpen) {
|
||||
store.closePanel();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [store]);
|
||||
|
||||
// Avoid full-page flashing on initial load; only show this overlay for manual refresh.
|
||||
const showLoading =
|
||||
store.loadingState.cities ||
|
||||
store.loadingState.cityDetail ||
|
||||
store.loadingState.refresh;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<MapCanvas />
|
||||
<HeaderBar />
|
||||
<CitySidebar />
|
||||
<DetailPanel />
|
||||
<GuideModal />
|
||||
<HistoryModal />
|
||||
<FutureForecastModal />
|
||||
{showLoading && (
|
||||
<div className="loading-overlay">
|
||||
<div className="loading-spinner" />
|
||||
<span>正在获取气象数据,请稍候...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PolyWeatherDashboard() {
|
||||
return (
|
||||
<DashboardStoreProvider>
|
||||
<DashboardScreen />
|
||||
</DashboardStoreProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user