feat: Implement the PolyWeather dashboard including frontend components, data collection, analysis, and API endpoints.
This commit is contained in:
@@ -19,3 +19,16 @@ HTTP_PROXY=http://127.0.0.1:7890
|
||||
LOG_LEVEL=INFO
|
||||
ENV=production
|
||||
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
|
||||
|
||||
# Polymarket P0 Read-Only Market Layer
|
||||
POLYMARKET_MARKET_SCAN_ENABLED=true
|
||||
POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com
|
||||
POLYMARKET_CLOB_URL=https://clob.polymarket.com
|
||||
POLYMARKET_CHAIN_ID=137
|
||||
POLYMARKET_HTTP_TIMEOUT_SEC=8
|
||||
POLYMARKET_MARKET_CACHE_TTL_SEC=180
|
||||
POLYMARKET_PRICE_CACHE_TTL_SEC=10
|
||||
POLYMARKET_DISCOVERY_PAGES=6
|
||||
POLYMARKET_DISCOVERY_LIMIT=200
|
||||
POLYMARKET_SIGNAL_MIN_LIQUIDITY=500
|
||||
POLYMARKET_SIGNAL_EDGE_PCT=2
|
||||
|
||||
+13
-2
@@ -137,7 +137,10 @@
|
||||
|
||||
- **URL**: `/api/city/{name}/detail`
|
||||
- **Method**: `GET`
|
||||
- **用途**: 面向后续商业化聚合视图的单请求聚合接口。
|
||||
- **用途**: 商业化聚合视图单请求接口(已接入 P0 只读价格层)。
|
||||
- **可选参数**:
|
||||
- `force_refresh=true|false`
|
||||
- `market_slug=<slug>`(调试优先直查;传入后优先按 slug 精确定位市场,跳过自动发现)
|
||||
|
||||
**当前结构**
|
||||
|
||||
@@ -153,7 +156,15 @@
|
||||
**说明**
|
||||
|
||||
- 当前生产前端主链路仍以 `/api/city/{name}` + `/api/history/{name}` 为主。
|
||||
- `/api/city/{name}/detail` 已提供聚合结构,供后续产品层扩展接入。
|
||||
- `/api/city/{name}/detail` 当前已包含 Polymarket P0 只读字段:
|
||||
- `primary_market`
|
||||
- `selected_condition_id`
|
||||
- `yes_token` / `no_token`
|
||||
- `yes_buy` / `yes_sell` / `no_buy` / `no_sell`
|
||||
- `market_price`(优先 midpoint)
|
||||
- `edge_percent`(`(model_probability - market_price) * 100`)
|
||||
- `signal_label`(`BUY YES` / `BUY NO` / `MONITOR`)
|
||||
- `websocket.asset_ids` / `websocket.condition_ids`(仅用于订阅标识,P0 不下单)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const marketSlug = req.nextUrl.searchParams.get("market_slug");
|
||||
const searchParams = new URLSearchParams({
|
||||
force_refresh: forceRefresh,
|
||||
});
|
||||
if (marketSlug) {
|
||||
searchParams.set("market_slug", marketSlug);
|
||||
}
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch city detail aggregate", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import type { Metadata } from "next";
|
||||
import { DashboardEntry } from "@/components/dashboard/DashboardEntry";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather - 天气衍生品智能地图",
|
||||
title: "PolyWeather - Global Weather Intelligence Map",
|
||||
description:
|
||||
"PolyWeather 天气衍生品智能地图,聚合 METAR、MGM、DEB、多模型预报与历史对账分析。",
|
||||
"PolyWeather dashboard with METAR, MGM, DEB fusion forecast, multi-model comparison, and history reconciliation.",
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 / METAR、Turkish MGM、Open-Meteo、weather.gov
|
||||
为主,部分城市补充 Meteoblue。
|
||||
</div>
|
||||
<div className="guide-footer">{t("guide.footer")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ interface DashboardStoreValue extends DashboardState {
|
||||
openFutureModal: (dateStr: string) => void;
|
||||
openGuide: () => void;
|
||||
openHistory: () => Promise<void>;
|
||||
openTodayModal: () => void;
|
||||
openTodayModal: () => Promise<void>;
|
||||
registerMapStopMotion: (stopMotion: () => void) => void;
|
||||
refreshAll: () => Promise<void>;
|
||||
refreshSelectedCity: () => Promise<void>;
|
||||
@@ -64,6 +64,96 @@ function getInitialHistoryState(): HistoryState {
|
||||
};
|
||||
}
|
||||
|
||||
const AI_EMPTY_PATTERNS = [
|
||||
/暂无\s*AI\s*分析/i,
|
||||
/当前以结构化气象与模型数据为主/i,
|
||||
/No\s*AI\s*analysis\s*available/i,
|
||||
/Structured\s+meteorological\s+and\s+model\s+data/i,
|
||||
];
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function extractAiPayload(analysis: CityDetail["ai_analysis"]) {
|
||||
if (!analysis) {
|
||||
return {
|
||||
bullets: [] as string[],
|
||||
summary: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof analysis === "string") {
|
||||
return {
|
||||
bullets: [] as string[],
|
||||
summary: normalizeText(analysis),
|
||||
};
|
||||
}
|
||||
|
||||
const summary =
|
||||
normalizeText(analysis.summary) ||
|
||||
normalizeText(analysis.text) ||
|
||||
normalizeText(analysis.message);
|
||||
const bulletsSource = Array.isArray(analysis.highlights)
|
||||
? analysis.highlights
|
||||
: Array.isArray(analysis.points)
|
||||
? analysis.points
|
||||
: [];
|
||||
|
||||
return {
|
||||
bullets: bulletsSource.map((item) => normalizeText(item)).filter(Boolean),
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
function hasMeaningfulAiAnalysis(analysis: CityDetail["ai_analysis"]) {
|
||||
const parsed = extractAiPayload(analysis);
|
||||
const hasBullets = parsed.bullets.length > 0;
|
||||
const hasSummary =
|
||||
Boolean(parsed.summary) &&
|
||||
!AI_EMPTY_PATTERNS.some((pattern) => pattern.test(parsed.summary));
|
||||
return hasBullets || hasSummary;
|
||||
}
|
||||
|
||||
function normalizeMetarSignature(detail?: CityDetail) {
|
||||
if (!detail) return "";
|
||||
const metar = normalizeText(detail.current?.raw_metar)
|
||||
.replace(/\s+/g, " ")
|
||||
.toUpperCase();
|
||||
const obsTime = normalizeText(detail.current?.obs_time);
|
||||
return [metar, obsTime].filter(Boolean).join("|");
|
||||
}
|
||||
|
||||
function mergeAiAnalysisIfStable(
|
||||
previousDetail: CityDetail | undefined,
|
||||
nextDetail: CityDetail,
|
||||
) {
|
||||
if (!previousDetail) return nextDetail;
|
||||
if (hasMeaningfulAiAnalysis(nextDetail.ai_analysis)) return nextDetail;
|
||||
if (!hasMeaningfulAiAnalysis(previousDetail.ai_analysis)) return nextDetail;
|
||||
|
||||
const prevTemp = Number(previousDetail.current?.temp);
|
||||
const nextTemp = Number(nextDetail.current?.temp);
|
||||
const tempUnchanged =
|
||||
Number.isFinite(prevTemp) &&
|
||||
Number.isFinite(nextTemp) &&
|
||||
prevTemp === nextTemp;
|
||||
|
||||
const prevMetar = normalizeMetarSignature(previousDetail);
|
||||
const nextMetar = normalizeMetarSignature(nextDetail);
|
||||
const metarUnchanged =
|
||||
Boolean(prevMetar) && Boolean(nextMetar) && prevMetar === nextMetar;
|
||||
|
||||
if (!tempUnchanged && !metarUnchanged) {
|
||||
return nextDetail;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextDetail,
|
||||
ai_analysis: previousDetail.ai_analysis,
|
||||
};
|
||||
}
|
||||
|
||||
export function DashboardStoreProvider({
|
||||
children,
|
||||
}: {
|
||||
@@ -151,7 +241,8 @@ export function DashboardStoreProvider({
|
||||
}
|
||||
}
|
||||
|
||||
const detail = await dashboardClient.getCityDetail(cityName, { force });
|
||||
const latestDetail = await dashboardClient.getCityDetail(cityName, { force });
|
||||
const detail = mergeAiAnalysisIfStable(cached, latestDetail);
|
||||
setCityDetailsByName((current) => ({
|
||||
...current,
|
||||
[cityName]: detail,
|
||||
@@ -256,15 +347,22 @@ export function DashboardStoreProvider({
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
const previousSelectedDetail = selectedCity
|
||||
? cityDetailsByName[selectedCity]
|
||||
: undefined;
|
||||
dashboardClient.clearCityDetailCache();
|
||||
setCityDetailsByName({});
|
||||
setCityDetailMetaByName({});
|
||||
if (selectedCity) {
|
||||
setLoadingState((current) => ({ ...current, refresh: true }));
|
||||
try {
|
||||
const detail = await dashboardClient.getCityDetail(selectedCity, {
|
||||
const latestDetail = await dashboardClient.getCityDetail(selectedCity, {
|
||||
force: true,
|
||||
});
|
||||
const detail = mergeAiAnalysisIfStable(
|
||||
previousSelectedDetail,
|
||||
latestDetail,
|
||||
);
|
||||
setCityDetailsByName({ [selectedCity]: detail });
|
||||
setCitySummariesByName((current) => ({
|
||||
...current,
|
||||
@@ -335,10 +433,24 @@ export function DashboardStoreProvider({
|
||||
},
|
||||
openGuide: () => setIsGuideOpen(true),
|
||||
openHistory,
|
||||
openTodayModal: () => {
|
||||
if (selectedDetail?.local_date) {
|
||||
mapStopMotionRef.current();
|
||||
setFutureModalDate(selectedDetail.local_date);
|
||||
openTodayModal: async () => {
|
||||
if (!selectedCity || loadingState.cityDetail || loadingState.refresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
mapStopMotionRef.current();
|
||||
setLoadingState((current) => ({ ...current, refresh: true }));
|
||||
try {
|
||||
const detail = await ensureCityDetail(selectedCity, true);
|
||||
setSelectedForecastDate(detail.local_date);
|
||||
setFutureModalDate(detail.local_date);
|
||||
} catch {
|
||||
const fallback = cityDetailsByName[selectedCity];
|
||||
if (fallback?.local_date) {
|
||||
setFutureModalDate(fallback.local_date);
|
||||
}
|
||||
} finally {
|
||||
setLoadingState((current) => ({ ...current, refresh: false }));
|
||||
}
|
||||
},
|
||||
registerMapStopMotion: (stopMotion: () => void) => {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
formatMessage,
|
||||
getInitialLocaleFromNavigator,
|
||||
Locale,
|
||||
LOCALE_STORAGE_KEY,
|
||||
normalizeLocale,
|
||||
} from "@/lib/i18n";
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
toggleLocale: () => void;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [locale, setLocale] = useState<Locale>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return "zh-CN";
|
||||
}
|
||||
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
return stored ? normalizeLocale(stored) : getInitialLocaleFromNavigator();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
||||
document.documentElement.lang = locale;
|
||||
}, [locale]);
|
||||
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale,
|
||||
t: (key, params) => formatMessage(locale, key, params),
|
||||
toggleLocale: () => {
|
||||
setLocale((current) => (current === "zh-CN" ? "en-US" : "zh-CN"));
|
||||
},
|
||||
}),
|
||||
[locale],
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const context = useContext(I18nContext);
|
||||
if (!context) {
|
||||
throw new Error("useI18n must be used within I18nProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export function useLeafletMap({
|
||||
const nearbyLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const autoNearbyCityRef = useRef<string | null>(null);
|
||||
const loadingAutoNearbyRef = useRef(false);
|
||||
const handlingAutoNearbyRef = useRef(false);
|
||||
const lastMovedCityRef = useRef<string | null>(null);
|
||||
const suspendMotionRef = useRef(suspendMotion);
|
||||
const hasFittedInitialBoundsRef = useRef(false);
|
||||
@@ -335,77 +336,79 @@ export function useLeafletMap({
|
||||
}
|
||||
|
||||
async function maybeAutoShowNearbyStations() {
|
||||
if (suspendMotion) {
|
||||
map.stop();
|
||||
if (handlingAutoNearbyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedDetail) {
|
||||
// Just render stations, no camera move from here
|
||||
renderNearbyStations(selectedDetail, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no city selected, reset the move tracker
|
||||
lastMovedCityRef.current = null;
|
||||
|
||||
if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) {
|
||||
autoNearbyCityRef.current = null;
|
||||
layer.clearLayers();
|
||||
return;
|
||||
}
|
||||
|
||||
const center = map.getCenter();
|
||||
let best: { cityName: string; distance: number } | null = null;
|
||||
for (const [cityName, entry] of Object.entries(markersRef.current)) {
|
||||
const distance = map.distance(
|
||||
center,
|
||||
L.latLng(entry.city.lat, entry.city.lon),
|
||||
);
|
||||
if (distance > AUTO_NEARBY_MAX_DISTANCE_M) continue;
|
||||
if (!best || distance < best.distance) {
|
||||
best = { cityName, distance };
|
||||
}
|
||||
}
|
||||
|
||||
const targetCity = best?.cityName || null;
|
||||
if (!targetCity) {
|
||||
autoNearbyCityRef.current = null;
|
||||
layer.clearLayers();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
autoNearbyCityRef.current === targetCity &&
|
||||
layer.getLayers().length > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoNearbyCityRef.current = targetCity;
|
||||
const cachedDetail = cityDetailsByName[targetCity];
|
||||
if (cachedDetail) {
|
||||
renderNearbyStations(cachedDetail, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadingAutoNearbyRef.current) return;
|
||||
loadingAutoNearbyRef.current = true;
|
||||
handlingAutoNearbyRef.current = true;
|
||||
try {
|
||||
const detail = await onEnsureCityDetailRef.current(targetCity, false);
|
||||
renderNearbyStations(detail, true);
|
||||
} catch {
|
||||
if (selectedDetail) {
|
||||
// Just render stations, no camera move from here
|
||||
renderNearbyStations(selectedDetail, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (suspendMotion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If no city selected, reset the move tracker
|
||||
lastMovedCityRef.current = null;
|
||||
|
||||
if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) {
|
||||
autoNearbyCityRef.current = null;
|
||||
layer.clearLayers();
|
||||
return;
|
||||
}
|
||||
|
||||
const center = map.getCenter();
|
||||
let best: { cityName: string; distance: number } | null = null;
|
||||
for (const [cityName, entry] of Object.entries(markersRef.current)) {
|
||||
const distance = map.distance(
|
||||
center,
|
||||
L.latLng(entry.city.lat, entry.city.lon),
|
||||
);
|
||||
if (distance > AUTO_NEARBY_MAX_DISTANCE_M) continue;
|
||||
if (!best || distance < best.distance) {
|
||||
best = { cityName, distance };
|
||||
}
|
||||
}
|
||||
|
||||
const targetCity = best?.cityName || null;
|
||||
if (!targetCity) {
|
||||
autoNearbyCityRef.current = null;
|
||||
layer.clearLayers();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
autoNearbyCityRef.current === targetCity &&
|
||||
layer.getLayers().length > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoNearbyCityRef.current = targetCity;
|
||||
const cachedDetail = cityDetailsByName[targetCity];
|
||||
if (cachedDetail) {
|
||||
renderNearbyStations(cachedDetail, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadingAutoNearbyRef.current) return;
|
||||
loadingAutoNearbyRef.current = true;
|
||||
try {
|
||||
const detail = await onEnsureCityDetailRef.current(targetCity, false);
|
||||
renderNearbyStations(detail, true);
|
||||
} catch {
|
||||
} finally {
|
||||
loadingAutoNearbyRef.current = false;
|
||||
}
|
||||
} finally {
|
||||
loadingAutoNearbyRef.current = false;
|
||||
handlingAutoNearbyRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
const syncVisibility = () => {
|
||||
if (suspendMotion) {
|
||||
map.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (map.getZoom() < 7) {
|
||||
if (map.hasLayer(layer)) {
|
||||
map.removeLayer(layer);
|
||||
|
||||
+412
-168
@@ -1,55 +1,112 @@
|
||||
import { AiAnalysisStructured, CityDetail, HistoryPoint, NearbyStation } from "@/lib/dashboard-types";
|
||||
import { Locale } from "@/lib/i18n";
|
||||
import {
|
||||
AiAnalysisStructured,
|
||||
CityDetail,
|
||||
HistoryPoint,
|
||||
NearbyStation,
|
||||
} from "@/lib/dashboard-types";
|
||||
|
||||
const METAR_WX_MAP: Record<string, { label: string; icon: string }> = {
|
||||
RA: { label: "降雨", icon: "🌧️" },
|
||||
"-RA": { label: "小雨", icon: "🌦️" },
|
||||
"+RA": { label: "强降雨", icon: "⛈️" },
|
||||
SN: { label: "降雪", icon: "❄️" },
|
||||
"-SN": { label: "小雪", icon: "🌨️" },
|
||||
"+SN": { label: "大雪", icon: "🌨️" },
|
||||
DZ: { label: "毛毛雨", icon: "🌦️" },
|
||||
FG: { label: "雾", icon: "🌫️" },
|
||||
BR: { label: "薄雾", icon: "🌫️" },
|
||||
HZ: { label: "霾", icon: "🌫️" },
|
||||
TS: { label: "雷暴", icon: "⛈️" },
|
||||
VCTS: { label: "附近雷暴", icon: "⛈️" },
|
||||
SQ: { label: "飑线", icon: "💨" },
|
||||
GS: { label: "冰雹", icon: "🌨️" },
|
||||
const METAR_WX_MAP: Record<
|
||||
string,
|
||||
{ en: string; icon: string; zh: string }
|
||||
> = {
|
||||
RA: { en: "Rain", icon: "🌧️", zh: "降雨" },
|
||||
"-RA": { en: "Light rain", icon: "🌦️", zh: "小雨" },
|
||||
"+RA": { en: "Heavy rain", icon: "⛈️", zh: "强降雨" },
|
||||
SN: { en: "Snow", icon: "❄️", zh: "降雪" },
|
||||
"-SN": { en: "Light snow", icon: "🌨️", zh: "小雪" },
|
||||
"+SN": { en: "Heavy snow", icon: "🌨️", zh: "大雪" },
|
||||
DZ: { en: "Drizzle", icon: "🌦️", zh: "毛毛雨" },
|
||||
FG: { en: "Fog", icon: "🌫️", zh: "雾" },
|
||||
BR: { en: "Mist", icon: "🌫️", zh: "薄雾" },
|
||||
HZ: { en: "Haze", icon: "🌫️", zh: "霾" },
|
||||
TS: { en: "Thunderstorm", icon: "⛈️", zh: "雷暴" },
|
||||
VCTS: { en: "Nearby thunderstorm", icon: "⛈️", zh: "附近雷暴" },
|
||||
SQ: { en: "Squall", icon: "💨", zh: "飑线" },
|
||||
GS: { en: "Hail", icon: "🌨️", zh: "冰雹" },
|
||||
};
|
||||
|
||||
export function translateMetar(code?: string | null) {
|
||||
if (!code) return null;
|
||||
for (const [key, value] of Object.entries(METAR_WX_MAP)) {
|
||||
if (String(code).includes(key)) return value;
|
||||
}
|
||||
return { label: code, icon: "🌤️" };
|
||||
function isEnglish(locale: Locale) {
|
||||
return locale === "en-US";
|
||||
}
|
||||
|
||||
export function getRiskBadgeLabel(level?: string | null) {
|
||||
function normalizeCloudSummary(
|
||||
cloudDesc: string | null | undefined,
|
||||
locale: Locale,
|
||||
): { icon: string; text: string } {
|
||||
const raw = String(cloudDesc || "").trim();
|
||||
if (!raw) {
|
||||
return { icon: "🔍", text: isEnglish(locale) ? "Unknown" : "未知" };
|
||||
}
|
||||
|
||||
const lower = raw.toLowerCase();
|
||||
if (
|
||||
raw.includes("晴") ||
|
||||
raw.includes("晴朗") ||
|
||||
lower.includes("clear") ||
|
||||
lower.includes("sunny")
|
||||
) {
|
||||
return { icon: "☀️", text: isEnglish(locale) ? "Clear" : "晴朗" };
|
||||
}
|
||||
if (raw.includes("阴") || lower.includes("overcast")) {
|
||||
return { icon: "☁️", text: isEnglish(locale) ? "Overcast" : "阴天" };
|
||||
}
|
||||
if (raw.includes("多云") || lower.includes("cloud")) {
|
||||
return { icon: "☁️", text: isEnglish(locale) ? "Cloudy" : "多云" };
|
||||
}
|
||||
if (raw.includes("少云") || lower.includes("few")) {
|
||||
return { icon: "🌤️", text: isEnglish(locale) ? "Mostly clear" : "少云" };
|
||||
}
|
||||
if (raw.includes("散云") || lower.includes("scattered")) {
|
||||
return { icon: "⛅", text: isEnglish(locale) ? "Partly cloudy" : "散云" };
|
||||
}
|
||||
return { icon: "🔍", text: raw };
|
||||
}
|
||||
|
||||
export function translateMetar(code?: string | null, locale: Locale = "zh-CN") {
|
||||
if (!code) return null;
|
||||
const metarCode = String(code);
|
||||
for (const [key, value] of Object.entries(METAR_WX_MAP)) {
|
||||
if (metarCode.includes(key)) {
|
||||
return {
|
||||
icon: value.icon,
|
||||
label: isEnglish(locale) ? value.en : value.zh,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { icon: "🔍", label: metarCode };
|
||||
}
|
||||
|
||||
export function getRiskBadgeLabel(
|
||||
level?: string | null,
|
||||
locale: Locale = "zh-CN",
|
||||
) {
|
||||
if (isEnglish(locale)) {
|
||||
return (
|
||||
{
|
||||
high: "🔴 High Risk",
|
||||
low: "🟢 Low Risk",
|
||||
medium: "🟠 Medium Risk",
|
||||
}[String(level || "low")] || "Unknown Risk"
|
||||
);
|
||||
}
|
||||
return (
|
||||
{
|
||||
high: "🔴 高风险",
|
||||
medium: "🟠 中风险",
|
||||
low: "🟢 低风险",
|
||||
medium: "🟠 中风险",
|
||||
}[String(level || "low")] || "未知风险"
|
||||
);
|
||||
}
|
||||
|
||||
export function getWeatherSummary(detail: CityDetail) {
|
||||
export function getWeatherSummary(detail: CityDetail, locale: Locale = "zh-CN") {
|
||||
const current = detail.current || {};
|
||||
let weatherText = current.cloud_desc || "未知";
|
||||
let weatherIcon =
|
||||
{
|
||||
多云: "☁️",
|
||||
阴天: "☁️",
|
||||
少云: "🌤️",
|
||||
散云: "⛅",
|
||||
晴: "☀️",
|
||||
晴朗: "☀️",
|
||||
}[String(current.cloud_desc || "")] || "🌤️";
|
||||
const cloud = normalizeCloudSummary(current.cloud_desc, locale);
|
||||
let weatherText = cloud.text;
|
||||
let weatherIcon = cloud.icon;
|
||||
|
||||
if (current.wx_desc) {
|
||||
const translated = translateMetar(current.wx_desc);
|
||||
const translated = translateMetar(current.wx_desc, locale);
|
||||
if (translated) {
|
||||
weatherText = translated.label;
|
||||
weatherIcon = translated.icon;
|
||||
@@ -59,25 +116,28 @@ export function getWeatherSummary(detail: CityDetail) {
|
||||
return { weatherIcon, weatherText };
|
||||
}
|
||||
|
||||
export function getHeroMetaItems(detail: CityDetail) {
|
||||
export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
|
||||
const current = detail.current || {};
|
||||
const parts: string[] = [];
|
||||
|
||||
if (current.obs_time) {
|
||||
const ageText =
|
||||
current.obs_age_min != null && current.obs_age_min >= 30
|
||||
? `(${current.obs_age_min} 分钟前)`
|
||||
? isEnglish(locale)
|
||||
? ` (${current.obs_age_min} min ago)`
|
||||
: `(${current.obs_age_min} 分钟前)`
|
||||
: "";
|
||||
parts.push(`✈️ METAR ${current.obs_time}${ageText}`);
|
||||
}
|
||||
|
||||
if (current.wx_desc) {
|
||||
const translated = translateMetar(current.wx_desc);
|
||||
const translated = translateMetar(current.wx_desc, locale);
|
||||
if (translated) {
|
||||
parts.push(`${translated.icon} ${translated.label}`);
|
||||
}
|
||||
} else if (current.cloud_desc) {
|
||||
parts.push(`☁️ ${current.cloud_desc}`);
|
||||
const cloud = normalizeCloudSummary(current.cloud_desc, locale);
|
||||
parts.push(`${cloud.icon} ${cloud.text}`);
|
||||
}
|
||||
|
||||
if (current.wind_speed_kt != null) {
|
||||
@@ -91,26 +151,40 @@ export function getHeroMetaItems(detail: CityDetail) {
|
||||
if (detail.mgm?.temp != null) {
|
||||
const timeMatch = detail.mgm.time?.match(/T?(\d{2}:\d{2})/);
|
||||
const timeText = timeMatch ? ` @${timeMatch[1]}` : "";
|
||||
parts.push(`📡 MGM 实测: ${detail.mgm.temp}${detail.temp_symbol}${timeText}`);
|
||||
parts.push(
|
||||
isEnglish(locale)
|
||||
? `🛰 MGM Obs: ${detail.mgm.temp}${detail.temp_symbol}${timeText}`
|
||||
: `🛰 MGM 实测: ${detail.mgm.temp}${detail.temp_symbol}${timeText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const trend = detail.trend || {};
|
||||
if (trend.is_dead_market) {
|
||||
parts.push("☠️ 死盘");
|
||||
parts.push(isEnglish(locale) ? "☠️ Flat market" : "☠️ 死盘");
|
||||
} else if (trend.direction && trend.direction !== "unknown") {
|
||||
const labels: Record<string, string> = {
|
||||
rising: "📈 升温中",
|
||||
falling: "📉 降温中",
|
||||
stagnant: "⏸️ 持平",
|
||||
mixed: "📊 波动中",
|
||||
};
|
||||
const labels: Record<string, string> = isEnglish(locale)
|
||||
? {
|
||||
falling: "📉 Cooling",
|
||||
mixed: "📊 Choppy",
|
||||
rising: "📈 Warming",
|
||||
stagnant: "⏸ Flat",
|
||||
}
|
||||
: {
|
||||
falling: "📉 降温中",
|
||||
mixed: "📊 波动中",
|
||||
rising: "📈 升温中",
|
||||
stagnant: "⏸ 持平",
|
||||
};
|
||||
parts.push(labels[trend.direction] || trend.direction);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function getTemperatureChartData(detail: CityDetail) {
|
||||
export function getTemperatureChartData(
|
||||
detail: CityDetail,
|
||||
locale: Locale = "zh-CN",
|
||||
) {
|
||||
const hourly = detail.hourly || {};
|
||||
const times = hourly.times || [];
|
||||
const temps = hourly.temps || [];
|
||||
@@ -199,10 +273,18 @@ export function getTemperatureChartData(detail: CityDetail) {
|
||||
}
|
||||
if (!hasMgmHourly && debMax != null && omMax != null && Math.abs(offset) > 0.3) {
|
||||
const sign = offset > 0 ? "+" : "";
|
||||
legendParts.push(`DEB 偏移 ${sign}${offset.toFixed(1)}${detail.temp_symbol} vs OM`);
|
||||
legendParts.push(
|
||||
isEnglish(locale)
|
||||
? `DEB offset ${sign}${offset.toFixed(1)}${detail.temp_symbol} vs OM`
|
||||
: `DEB 偏移 ${sign}${offset.toFixed(1)}${detail.temp_symbol} vs OM`,
|
||||
);
|
||||
}
|
||||
if (hasMgmHourly) {
|
||||
legendParts.push("已使用 MGM 小时预报替代 DEB 曲线");
|
||||
legendParts.push(
|
||||
isEnglish(locale)
|
||||
? "Using MGM hourly forecast to replace DEB curve"
|
||||
: "已使用 MGM 小时预报替代 DEB 曲线",
|
||||
);
|
||||
}
|
||||
if (detail.trend?.recent?.length) {
|
||||
const recentText = [...detail.trend.recent]
|
||||
@@ -354,7 +436,17 @@ function trendBucketFromDir(direction?: number | null) {
|
||||
return "westerly";
|
||||
}
|
||||
|
||||
function bucketLabel(bucket: string | null) {
|
||||
function bucketLabel(bucket: string | null, locale: Locale = "zh-CN") {
|
||||
if (isEnglish(locale)) {
|
||||
return (
|
||||
{
|
||||
southerly: "S / SW wind",
|
||||
northerly: "N / NW wind",
|
||||
easterly: "E wind",
|
||||
westerly: "W wind",
|
||||
}[bucket || ""] || "Unknown wind direction"
|
||||
);
|
||||
}
|
||||
return (
|
||||
{
|
||||
southerly: "南 / 西南风",
|
||||
@@ -365,6 +457,14 @@ function bucketLabel(bucket: string | null) {
|
||||
);
|
||||
}
|
||||
|
||||
export function wuRound(value: number | null | undefined) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
return numeric >= 0
|
||||
? Math.floor(numeric + 0.5)
|
||||
: Math.ceil(numeric - 0.5);
|
||||
}
|
||||
|
||||
export function formatDelta(value: number | null | undefined, suffix = "") {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return "--";
|
||||
@@ -379,7 +479,11 @@ function getForecastTextForDate(detail: CityDetail, dateStr: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export function computeFrontTrendSignal(detail: CityDetail, dateStr: string) {
|
||||
export function computeFrontTrendSignal(
|
||||
detail: CityDetail,
|
||||
dateStr: string,
|
||||
locale: Locale = "zh-CN",
|
||||
) {
|
||||
const slice = getFutureSlice(detail, dateStr);
|
||||
const currentTemp = Number(detail.current?.temp);
|
||||
const currentDew = Number(detail.current?.dewpoint);
|
||||
@@ -387,7 +491,7 @@ export function computeFrontTrendSignal(detail: CityDetail, dateStr: string) {
|
||||
if (!slice.length) {
|
||||
return {
|
||||
confidence: "low",
|
||||
label: "监控中",
|
||||
label: isEnglish(locale) ? "Monitoring" : "监控中",
|
||||
metrics: [] as Array<{
|
||||
label: string;
|
||||
note: string;
|
||||
@@ -396,7 +500,9 @@ export function computeFrontTrendSignal(detail: CityDetail, dateStr: string) {
|
||||
}>,
|
||||
precipMax: 0,
|
||||
score: 0,
|
||||
summary: "未来 48 小时结构化数据不足,暂时只保留基础监控。",
|
||||
summary: isEnglish(locale)
|
||||
? "Insufficient 48h structured data. Keep baseline monitoring."
|
||||
: "未来 48 小时结构化数据不足,暂时只保留基础监控。",
|
||||
weatherGovPeriods: [] as ReturnType<typeof getForecastTextForDate>,
|
||||
};
|
||||
}
|
||||
@@ -480,12 +586,14 @@ export function computeFrontTrendSignal(detail: CityDetail, dateStr: string) {
|
||||
}
|
||||
|
||||
const score = Math.max(-100, Math.min(100, warmScore - coldScore));
|
||||
const label =
|
||||
score >= 18
|
||||
? "暖平流 / 暖锋倾向"
|
||||
: score <= -18
|
||||
? "冷平流 / 冷锋倾向"
|
||||
: "监控中";
|
||||
const warmLabel = isEnglish(locale)
|
||||
? "Warm advection / warm-front tendency"
|
||||
: "暖平流 / 暖锋倾向";
|
||||
const coldLabel = isEnglish(locale)
|
||||
? "Cold advection / cold-front tendency"
|
||||
: "冷平流 / 冷锋倾向";
|
||||
const monitorLabel = isEnglish(locale) ? "Monitoring" : "监控中";
|
||||
const label = score >= 18 ? warmLabel : score <= -18 ? coldLabel : monitorLabel;
|
||||
const confidence =
|
||||
Math.abs(score) >= 45 ? "high" : Math.abs(score) >= 22 ? "medium" : "low";
|
||||
|
||||
@@ -494,37 +602,47 @@ export function computeFrontTrendSignal(detail: CityDetail, dateStr: string) {
|
||||
label,
|
||||
metrics: [
|
||||
{
|
||||
label: "温度变化",
|
||||
note: "Open-Meteo 未来小时温度变化",
|
||||
label: isEnglish(locale) ? "Temperature delta" : "温度变化",
|
||||
note: isEnglish(locale)
|
||||
? "Open-Meteo upcoming hourly temperature change"
|
||||
: "Open-Meteo 未来小时温度变化",
|
||||
tone: tempDelta >= 0.8 ? "warm" : tempDelta <= -0.8 ? "cold" : "",
|
||||
value: formatDelta(tempDelta, detail.temp_symbol),
|
||||
},
|
||||
{
|
||||
label: "露点变化",
|
||||
note: "露点上升更偏向暖湿平流",
|
||||
label: isEnglish(locale) ? "Dew point delta" : "露点变化",
|
||||
note: isEnglish(locale)
|
||||
? "Rising dew point often supports warm/wet advection"
|
||||
: "露点上升更偏向暖湿平流",
|
||||
tone: dewDelta >= 0.8 ? "warm" : dewDelta <= -0.8 ? "cold" : "",
|
||||
value: formatDelta(dewDelta, detail.temp_symbol),
|
||||
},
|
||||
{
|
||||
label: "气压变化",
|
||||
note: "气压回升更偏向冷空气压入",
|
||||
label: isEnglish(locale) ? "Pressure delta" : "气压变化",
|
||||
note: isEnglish(locale)
|
||||
? "Pressure rebound usually implies cold-air push"
|
||||
: "气压回升更偏向冷空气压入",
|
||||
tone: pressureDelta >= 1 ? "cold" : pressureDelta <= -1 ? "warm" : "",
|
||||
value: formatDelta(pressureDelta, " hPa"),
|
||||
},
|
||||
{
|
||||
label: "风向演变",
|
||||
note: "关注是否转南风或转北风",
|
||||
value: `${bucketLabel(firstBucket)} -> ${bucketLabel(lastBucket)}`,
|
||||
label: isEnglish(locale) ? "Wind-direction evolution" : "风向演变",
|
||||
note: isEnglish(locale)
|
||||
? "Focus on switch to southerly or northerly flow"
|
||||
: "关注是否转南风或转北风",
|
||||
value: `${bucketLabel(firstBucket, locale)} -> ${bucketLabel(lastBucket, locale)}`,
|
||||
},
|
||||
{
|
||||
label: "降水概率",
|
||||
note: "weather.gov / Open-Meteo 降水提示",
|
||||
label: isEnglish(locale) ? "Precip probability" : "降水概率",
|
||||
note: "weather.gov / Open-Meteo",
|
||||
tone: precipMax >= 50 ? "cold" : "",
|
||||
value: `${Math.round(precipMax)}%`,
|
||||
},
|
||||
{
|
||||
label: "云量变化",
|
||||
note: "云量抬升但未降温,常见于暖平流前段",
|
||||
label: isEnglish(locale) ? "Cloud-cover delta" : "云量变化",
|
||||
note: isEnglish(locale)
|
||||
? "Cloud increase without cooling may imply warm advection"
|
||||
: "云量抬升但未降温,常见于暖平流前段",
|
||||
tone:
|
||||
cloudDelta >= 15 && tempDelta >= 0
|
||||
? "warm"
|
||||
@@ -537,18 +655,30 @@ export function computeFrontTrendSignal(detail: CityDetail, dateStr: string) {
|
||||
precipMax,
|
||||
score,
|
||||
summary:
|
||||
label === "暖平流 / 暖锋倾向"
|
||||
? "风向更偏南 / 西南,露点与温度整体抬升,未来 6-48 小时偏向暖平流。"
|
||||
: label === "冷平流 / 冷锋倾向"
|
||||
? "温度下滑、气压回升或风向转北,未来 6-48 小时更像冷锋或冷平流压制。"
|
||||
label === warmLabel
|
||||
? isEnglish(locale)
|
||||
? "Southerly flow strengthens with rising dew point and temperature. Next 6-48h leans warm advection."
|
||||
: "风向更偏南 / 西南,露点与温度整体抬升,未来 6-48 小时偏向暖平流。"
|
||||
: label === coldLabel
|
||||
? isEnglish(locale)
|
||||
? "Temperature declines with pressure rebound and/or northerly shift. Next 6-48h leans cold-front suppression."
|
||||
: "温度下滑、气压回升或风向转北,未来 6-48 小时更像冷锋或冷平流压制。"
|
||||
: detail.name !== "ankara" && Boolean(detail.source_forecasts?.meteoblue)
|
||||
? "结构化来源以 weather.gov、Open-Meteo、Meteoblue 为主,用于判断未来 6-48 小时冷暖平流趋势。"
|
||||
: "结构化来源以 weather.gov 与 Open-Meteo 为主,用于判断未来 6-48 小时冷暖平流趋势。",
|
||||
? isEnglish(locale)
|
||||
? "Structured trend layer mainly uses weather.gov, Open-Meteo and Meteoblue for 6-48h warm/cold flow judgement."
|
||||
: "结构化来源以 weather.gov、Open-Meteo、Meteoblue 为主,用于判断未来 6-48 小时冷暖平流趋势。"
|
||||
: isEnglish(locale)
|
||||
? "Structured trend layer mainly uses weather.gov and Open-Meteo for 6-48h warm/cold flow judgement."
|
||||
: "结构化来源以 weather.gov 和 Open-Meteo 为主,用于判断未来 6-48 小时冷暖平流趋势。",
|
||||
weatherGovPeriods,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFutureModalView(detail: CityDetail, dateStr: string) {
|
||||
export function getFutureModalView(
|
||||
detail: CityDetail,
|
||||
dateStr: string,
|
||||
locale: Locale = "zh-CN",
|
||||
) {
|
||||
const forecastEntry =
|
||||
detail.forecast?.daily?.find((item) => item.date === dateStr) || null;
|
||||
const dailyModel = detail.multi_model_daily?.[dateStr] || {};
|
||||
@@ -571,7 +701,7 @@ export function getFutureModalView(detail: CityDetail, dateStr: string) {
|
||||
return {
|
||||
deb,
|
||||
forecastEntry,
|
||||
front: computeFrontTrendSignal(detail, dateStr),
|
||||
front: computeFrontTrendSignal(detail, dateStr, locale),
|
||||
models: dailyModel.models || {},
|
||||
mu: Number.isFinite(Number(mu)) ? Number(mu) : null,
|
||||
probabilities,
|
||||
@@ -579,7 +709,11 @@ export function getFutureModalView(detail: CityDetail, dateStr: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export function getShortTermNowcastLines(detail: CityDetail, dateStr: string) {
|
||||
export function getShortTermNowcastLines(
|
||||
detail: CityDetail,
|
||||
dateStr: string,
|
||||
locale: Locale = "zh-CN",
|
||||
) {
|
||||
const slice = getFutureSlice(detail, dateStr);
|
||||
if (dateStr !== detail.local_date) {
|
||||
const afternoon = slice.filter((point) => {
|
||||
@@ -589,8 +723,13 @@ export function getShortTermNowcastLines(detail: CityDetail, dateStr: string) {
|
||||
const target = afternoon.length ? afternoon : slice;
|
||||
if (!target.length) {
|
||||
return [
|
||||
["目标日期", dateStr],
|
||||
["峰值窗口", "暂无足够的小时级 forecast 数据,无法生成目标日午后峰值窗口判断。"],
|
||||
[isEnglish(locale) ? "Target date" : "目标日期", dateStr],
|
||||
[
|
||||
isEnglish(locale) ? "Peak window" : "峰值窗口",
|
||||
isEnglish(locale)
|
||||
? "No sufficient hourly forecast data for target-day peak-window diagnostics."
|
||||
: "暂无足够的小时级 forecast 数据,无法生成目标日午后峰值窗口判断。",
|
||||
],
|
||||
] as const;
|
||||
}
|
||||
|
||||
@@ -625,23 +764,45 @@ export function getShortTermNowcastLines(detail: CityDetail, dateStr: string) {
|
||||
const maxCloud = cloudValues.length ? Math.max(...cloudValues) : 0;
|
||||
|
||||
return [
|
||||
["目标日期", dateStr],
|
||||
["峰值窗口", `${start.label} - ${end.label}(优先取 12:00-18:00)`],
|
||||
[isEnglish(locale) ? "Target date" : "目标日期", dateStr],
|
||||
[
|
||||
"峰值预估",
|
||||
isEnglish(locale) ? "Peak window" : "峰值窗口",
|
||||
isEnglish(locale)
|
||||
? `${start.label} - ${end.label} (prefer 12:00-18:00)`
|
||||
: `${start.label} - ${end.label}(优先取 12:00-18:00)`,
|
||||
],
|
||||
[
|
||||
isEnglish(locale) ? "Peak estimate" : "峰值预估",
|
||||
`${Number.isFinite(Number(peakPoint.temp)) ? Number(peakPoint.temp).toFixed(1) : "--"}${detail.temp_symbol} @ ${peakPoint.label || "--"}`,
|
||||
],
|
||||
[
|
||||
"窗口温度",
|
||||
`${Number.isFinite(startTemp) ? startTemp.toFixed(1) : "--"}${detail.temp_symbol} -> ${Number.isFinite(endTemp) ? endTemp.toFixed(1) : "--"}${detail.temp_symbol}(${formatDelta(endTemp - startTemp, detail.temp_symbol)})`,
|
||||
isEnglish(locale) ? "Window temperature" : "窗口温度",
|
||||
`${Number.isFinite(startTemp) ? startTemp.toFixed(1) : "--"}${detail.temp_symbol} -> ${Number.isFinite(endTemp) ? endTemp.toFixed(1) : "--"}${detail.temp_symbol} (${formatDelta(endTemp - startTemp, detail.temp_symbol)})`,
|
||||
],
|
||||
["露点变化", `${formatDelta(endDew - startDew, detail.temp_symbol)},用于判断午后暖湿输送是否增强。`],
|
||||
[
|
||||
"风向演变",
|
||||
`${bucketLabel(trendBucketFromDir(start.windDir))} -> ${bucketLabel(trendBucketFromDir(end.windDir))},关注峰值前后是否转南风或回摆北风。`,
|
||||
isEnglish(locale) ? "Dew-point delta" : "露点变化",
|
||||
isEnglish(locale)
|
||||
? `${formatDelta(endDew - startDew, detail.temp_symbol)} for diagnosing warm/wet transport in afternoon.`
|
||||
: `${formatDelta(endDew - startDew, detail.temp_symbol)},用于判断午后暖湿输送是否增强。`,
|
||||
],
|
||||
[
|
||||
isEnglish(locale) ? "Wind shift" : "风向演变",
|
||||
isEnglish(locale)
|
||||
? `${bucketLabel(trendBucketFromDir(start.windDir), locale)} -> ${bucketLabel(trendBucketFromDir(end.windDir), locale)} around peak window.`
|
||||
: `${bucketLabel(trendBucketFromDir(start.windDir), locale)} -> ${bucketLabel(trendBucketFromDir(end.windDir), locale)},关注峰值前后是否转南风或回摆北风。`,
|
||||
],
|
||||
[
|
||||
isEnglish(locale) ? "Pressure delta" : "气压变化",
|
||||
isEnglish(locale)
|
||||
? `${formatDelta(endPressure - startPressure, " hPa")} (higher pressure usually favors cold-air push).`
|
||||
: `${formatDelta(endPressure - startPressure, " hPa")},上升更偏向冷空气压入。`,
|
||||
],
|
||||
[
|
||||
isEnglish(locale) ? "Precip / cloud" : "降水 / 云量",
|
||||
isEnglish(locale)
|
||||
? `${Math.round(maxPrecip)}% / ${Math.round(maxCloud)}% for cloud-suppression judgement around peak hours.`
|
||||
: `${Math.round(maxPrecip)}% / ${Math.round(maxCloud)}%,用于判断峰值时段是否受云系压制。`,
|
||||
],
|
||||
["气压变化", `${formatDelta(endPressure - startPressure, " hPa")},上升更偏向冷空气压入。`],
|
||||
["降水 / 云量", `${Math.round(maxPrecip)}% / ${Math.round(maxCloud)}%,用于判断峰值时段是否受云系压制。`],
|
||||
] as const;
|
||||
}
|
||||
|
||||
@@ -649,7 +810,14 @@ export function getShortTermNowcastLines(detail: CityDetail, dateStr: string) {
|
||||
? detail.metar_recent_obs.slice(-4)
|
||||
: [];
|
||||
const nearby = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : [];
|
||||
const sourceLabel = detail.name === "ankara" ? "MGM 周边站" : "METAR 周边站";
|
||||
const sourceLabel =
|
||||
detail.name === "ankara"
|
||||
? isEnglish(locale)
|
||||
? "MGM nearby stations"
|
||||
: "MGM 周边站"
|
||||
: isEnglish(locale)
|
||||
? "METAR nearby stations"
|
||||
: "METAR 周边站";
|
||||
const currentTemp = Number(detail.current?.temp);
|
||||
const recentTemps = recent
|
||||
.map((point) => Number(point.temp))
|
||||
@@ -668,25 +836,55 @@ export function getShortTermNowcastLines(detail: CityDetail, dateStr: string) {
|
||||
if (!nearbyLead || Math.abs(diff) > Math.abs(nearbyLead.diff)) {
|
||||
nearbyLead = {
|
||||
diff,
|
||||
name: station.name || station.icao || "周边站",
|
||||
name:
|
||||
station.name ||
|
||||
station.icao ||
|
||||
(isEnglish(locale) ? "Nearby station" : "周边站"),
|
||||
temp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const rows: Array<readonly [string, string]> = [
|
||||
["当前主站", `${detail.current?.temp ?? "--"}${detail.temp_symbol} @ ${detail.current?.obs_time || "--"}`],
|
||||
["原始 METAR", detail.current?.raw_metar || "暂无"],
|
||||
["近 0-2 小时", `${formatDelta(shortDelta, detail.temp_symbol)},依据最近 METAR 序列判断短时动量。`],
|
||||
[sourceLabel, `${nearby.length} 个站点参与邻近监控。`],
|
||||
[
|
||||
isEnglish(locale) ? "Primary station" : "当前主站",
|
||||
`${detail.current?.temp ?? "--"}${detail.temp_symbol} @ ${detail.current?.obs_time || "--"}`,
|
||||
],
|
||||
[
|
||||
isEnglish(locale) ? "Raw METAR" : "原始 METAR",
|
||||
detail.current?.raw_metar || (isEnglish(locale) ? "N/A" : "暂无"),
|
||||
],
|
||||
[
|
||||
isEnglish(locale) ? "Next 0-2h" : "近 0-2 小时",
|
||||
isEnglish(locale)
|
||||
? `${formatDelta(shortDelta, detail.temp_symbol)} based on latest METAR sequence short-term momentum.`
|
||||
: `${formatDelta(shortDelta, detail.temp_symbol)},依据最近 METAR 序列判断短时动量。`,
|
||||
],
|
||||
[
|
||||
sourceLabel,
|
||||
isEnglish(locale)
|
||||
? `${nearby.length} stations joined the nearby scan.`
|
||||
: `${nearby.length} 个站点参与邻近监控。`,
|
||||
],
|
||||
];
|
||||
|
||||
if (nearbyLead) {
|
||||
const tone =
|
||||
nearbyLead.diff > 0 ? "偏暖" : nearbyLead.diff < 0 ? "偏冷" : "持平";
|
||||
const tone = isEnglish(locale)
|
||||
? nearbyLead.diff > 0
|
||||
? "warmer"
|
||||
: nearbyLead.diff < 0
|
||||
? "cooler"
|
||||
: "flat"
|
||||
: nearbyLead.diff > 0
|
||||
? "偏暖"
|
||||
: nearbyLead.diff < 0
|
||||
? "偏冷"
|
||||
: "持平";
|
||||
rows.push([
|
||||
"领先站",
|
||||
`${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol},相对主站 ${formatDelta(nearbyLead.diff, detail.temp_symbol)}(${tone})。`,
|
||||
isEnglish(locale) ? "Leading station" : "领先站",
|
||||
isEnglish(locale)
|
||||
? `${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol}, relative to primary station ${formatDelta(nearbyLead.diff, detail.temp_symbol)} (${tone}).`
|
||||
: `${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol},相对主站 ${formatDelta(nearbyLead.diff, detail.temp_symbol)}(${tone})。`,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -719,7 +917,7 @@ export function getHistorySummary(
|
||||
settledData.forEach((row) => {
|
||||
if (row.actual != null && row.deb != null) {
|
||||
debErrors.push(Math.abs(row.actual - row.deb));
|
||||
if (Math.round(row.actual) === Math.round(row.deb)) {
|
||||
if (wuRound(row.actual) === wuRound(row.deb)) {
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
@@ -745,136 +943,182 @@ export function getHistorySummary(
|
||||
};
|
||||
}
|
||||
|
||||
export function getCityProfileStats(detail: CityDetail) {
|
||||
export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN") {
|
||||
const risk = detail.risk || {};
|
||||
const current = detail.current || {};
|
||||
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
|
||||
|
||||
return [
|
||||
{
|
||||
label: "结算机场",
|
||||
value: risk.airport && risk.icao ? `${risk.airport} (${risk.icao})` : "暂无档案",
|
||||
label: isEnglish(locale) ? "Settlement airport" : "结算机场",
|
||||
value:
|
||||
risk.airport && risk.icao
|
||||
? `${risk.airport} (${risk.icao})`
|
||||
: isEnglish(locale)
|
||||
? "No profile"
|
||||
: "暂无档案",
|
||||
},
|
||||
{
|
||||
label: "站点距离",
|
||||
label: isEnglish(locale) ? "Station distance" : "站点距离",
|
||||
value:
|
||||
risk.distance_km != null && Number.isFinite(Number(risk.distance_km))
|
||||
? `${risk.distance_km} km`
|
||||
: "未标注",
|
||||
: isEnglish(locale)
|
||||
? "Not marked"
|
||||
: "未标注",
|
||||
},
|
||||
{
|
||||
label: "观测更新",
|
||||
value: current.obs_time || detail.updated_at || "未提供",
|
||||
label: isEnglish(locale) ? "Observation update" : "观测更新",
|
||||
value:
|
||||
current.obs_time ||
|
||||
detail.updated_at ||
|
||||
(isEnglish(locale) ? "Unavailable" : "未提供"),
|
||||
},
|
||||
{
|
||||
label: "周边站点",
|
||||
value: nearbyCount > 0 ? `${nearbyCount} 个参与监控` : "暂无周边站",
|
||||
label: isEnglish(locale) ? "Nearby stations" : "周边站点",
|
||||
value:
|
||||
nearbyCount > 0
|
||||
? isEnglish(locale)
|
||||
? `${nearbyCount} participating stations`
|
||||
: `${nearbyCount} 个参与监控`
|
||||
: isEnglish(locale)
|
||||
? "No nearby stations"
|
||||
: "暂无周边站",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getSettlementRiskNarrative(detail: CityDetail) {
|
||||
export function getSettlementRiskNarrative(
|
||||
detail: CityDetail,
|
||||
locale: Locale = "zh-CN",
|
||||
) {
|
||||
const risk = detail.risk || {};
|
||||
const lines: string[] = [];
|
||||
|
||||
if (risk.warning) {
|
||||
lines.push(`当前主要风险是:${risk.warning}`);
|
||||
lines.push(
|
||||
isEnglish(locale)
|
||||
? `Current key risk: ${risk.warning}`
|
||||
: `当前主要风险是:${risk.warning}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (risk.distance_km != null) {
|
||||
if (risk.distance_km >= 60) {
|
||||
lines.push("结算机场与城市核心区域距离偏大,盘面温度与结算值可能出现明显背离。");
|
||||
lines.push(
|
||||
isEnglish(locale)
|
||||
? "Settlement airport is far from urban core; market feel and settlement value may diverge significantly."
|
||||
: "结算机场与城市核心区域距离偏大,盘面温度与结算值可能出现明显背离。",
|
||||
);
|
||||
} else if (risk.distance_km >= 25) {
|
||||
lines.push("结算机场与城区存在可感知距离,午后峰值和夜间降温节奏需要优先看机场站。");
|
||||
lines.push(
|
||||
isEnglish(locale)
|
||||
? "Settlement airport has material distance from downtown; peak/overnight rhythm should prioritize airport station."
|
||||
: "结算机场与城区存在可感知距离,午后峰值和夜间降温节奏需要优先看机场站。",
|
||||
);
|
||||
} else {
|
||||
lines.push("结算机场距离较近,城市体感与结算温度通常更同步。");
|
||||
lines.push(
|
||||
isEnglish(locale)
|
||||
? "Settlement airport is close enough; city feel and settlement temperature are usually more synchronized."
|
||||
: "结算机场距离较近,城市体感与结算温度通常更同步。",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (detail.name === "ankara") {
|
||||
lines.push("Ankara 需要重点看 LTAC / Esenboğa 与 MGM 周边站联动,不能只看城区体感。");
|
||||
lines.push(
|
||||
isEnglish(locale)
|
||||
? "For Ankara, focus on LTAC / Esenboğa plus MGM nearby-station linkage, not urban sensation alone."
|
||||
: "Ankara 需要重点看 LTAC / Esenboğa 与 MGM 周边站联动,不能只看城区体感。",
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.current?.obs_age_min != null) {
|
||||
if (detail.current.obs_age_min >= 45) {
|
||||
lines.push(`当前 METAR 已有 ${detail.current.obs_age_min} 分钟时滞,临近判断要结合周边站而不是只看主站快照。`);
|
||||
lines.push(
|
||||
isEnglish(locale)
|
||||
? `Current METAR is ${detail.current.obs_age_min} minutes old. Blend nearby stations for nowcast instead of single-station snapshot.`
|
||||
: `当前 METAR 已有 ${detail.current.obs_age_min} 分钟时滞,临近判断要结合周边站而不是只看主站快照。`,
|
||||
);
|
||||
} else {
|
||||
lines.push("当前主站观测较新,短时判断可以把主站温度作为主要锚点。");
|
||||
lines.push(
|
||||
isEnglish(locale)
|
||||
? "Primary station observation is fresh enough; short-term judgement can anchor on it."
|
||||
: "当前主站观测较新,短时判断可以把主站温度作为主要锚点。",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function getClimateDrivers(detail: CityDetail) {
|
||||
export function getClimateDrivers(detail: CityDetail, locale: Locale = "zh-CN") {
|
||||
const drivers: Array<{ label: string; text: string }> = [];
|
||||
const lat = Math.abs(Number(detail.lat));
|
||||
const current = detail.current || {};
|
||||
const temp = Number(current.temp);
|
||||
const dewPoint = Number(current.dewpoint);
|
||||
const humidity = Number(current.humidity);
|
||||
const windSpeed = Number(current.wind_speed_kt);
|
||||
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
|
||||
const nearbyCount = Array.isArray(detail.mgm_nearby)
|
||||
? detail.mgm_nearby.length
|
||||
: 0;
|
||||
const distanceKm = Number(detail.risk?.distance_km);
|
||||
|
||||
if (lat >= 50) {
|
||||
drivers.push({
|
||||
label: "高纬冷空气",
|
||||
text: "这座城市处在较高纬度,气温更容易受冷空气南下、短波槽和日照角度变化影响,波动通常偏快。",
|
||||
label: isEnglish(locale) ? "High-latitude cold air" : "高纬冷空气",
|
||||
text: isEnglish(locale)
|
||||
? "At higher latitude, temperature rhythm is more affected by cold-air surges, trough passage, and seasonal radiation angle."
|
||||
: "该城市位于较高纬度,温度变化更容易受到冷空气南下、短波槽和日照角度变化影响。",
|
||||
});
|
||||
} else if (lat >= 35) {
|
||||
drivers.push({
|
||||
label: "中纬度西风带",
|
||||
text: "这座城市主要受中纬度西风带和锋面活动控制,升温或降温往往来自气团切换,而不是单一的日照变化。",
|
||||
label: isEnglish(locale) ? "Mid-latitude westerlies" : "中纬西风带",
|
||||
text: isEnglish(locale)
|
||||
? "Temperature shifts are often controlled by frontal transitions rather than pure daytime radiation."
|
||||
: "该城市主要受中纬西风带和锋面活动控制,升降温常来自气团切换,而不是单一日照变化。",
|
||||
});
|
||||
} else if (lat >= 20) {
|
||||
drivers.push({
|
||||
label: "副热带高压",
|
||||
text: "这座城市更容易受到副热带高压、晴空辐射和低层暖平流影响,午后冲高能力通常比高纬城市更强。",
|
||||
label: isEnglish(locale) ? "Subtropical highs" : "副热带高压",
|
||||
text: isEnglish(locale)
|
||||
? "Subtropical ridge, clear-sky radiation and low-level warm advection often dominate warming efficiency."
|
||||
: "该城市更容易受副热带高压、晴空辐射和低层暖平流影响,午后增温能力通常更强。",
|
||||
});
|
||||
} else {
|
||||
drivers.push({
|
||||
label: "热带水汽与对流",
|
||||
text: "这座城市更偏热带环境,温度与体感常受水汽输送、云对流和阵雨触发影响,不完全由晴空辐射主导。",
|
||||
label: isEnglish(locale) ? "Tropical moisture & convection" : "热带水汽与对流",
|
||||
text: isEnglish(locale)
|
||||
? "Temperature and feels-like are often modulated by moisture transport, cloud convection and showers."
|
||||
: "该城市偏热带环境,温度与体感常受水汽输送、云对流和阵雨触发影响。",
|
||||
});
|
||||
}
|
||||
|
||||
if (Number.isFinite(windSpeed) && windSpeed >= 12) {
|
||||
drivers.push({
|
||||
label: "平流输送",
|
||||
text: `当前风速约 ${windSpeed}kt,说明低层输送比较明显,盘面短时方向更容易被外来气团带动。`,
|
||||
});
|
||||
} else if (detail.trend?.is_dead_market) {
|
||||
drivers.push({
|
||||
label: "本地辐射主导",
|
||||
text: "近期更像本地辐射和地表热量收支在主导,若无新气团介入,温度节奏通常更平滑。",
|
||||
});
|
||||
}
|
||||
drivers.push({
|
||||
label: isEnglish(locale) ? "Dry-wet boundary layer" : "干湿边界层",
|
||||
text: isEnglish(locale)
|
||||
? "Boundary-layer humidity controls daytime warming efficiency; dry boundary warms faster, wet boundary is more cloud/precip-sensitive."
|
||||
: "低层干湿状态会决定午后升温效率。干空气通常升温更快,湿空气更容易受云量和降水过程抑制。",
|
||||
});
|
||||
|
||||
if (
|
||||
Number.isFinite(temp) &&
|
||||
Number.isFinite(dewPoint) &&
|
||||
temp - dewPoint <= 3
|
||||
) {
|
||||
drivers.push({
|
||||
label: isEnglish(locale) ? "Advection transport" : "平流输送",
|
||||
text: isEnglish(locale)
|
||||
? "Short-term trend is usually driven by low-level air-mass transport. Persistent wind origin tends to sustain thermal direction."
|
||||
: "短时趋势常由低层气团输送控制。若风向持续来自同一侧,温度通常更容易沿该方向延续。",
|
||||
});
|
||||
|
||||
if (Number.isFinite(distanceKm) && distanceKm >= 25) {
|
||||
drivers.push({
|
||||
label: "湿度与云量约束",
|
||||
text: "当前温度和露点接近,说明低层湿度较高。午后峰值容易受云量和降水触发抑制。",
|
||||
});
|
||||
} else if (Number.isFinite(humidity) && humidity >= 70) {
|
||||
drivers.push({
|
||||
label: "湿层偏厚",
|
||||
text: "相对湿度偏高,说明局地升温效率会受到水汽和云层反馈影响,冲高空间要比干空气场景更小心。",
|
||||
});
|
||||
} else {
|
||||
drivers.push({
|
||||
label: "干暖边界层",
|
||||
text: "低层空气相对偏干,晴空时段的升温效率通常更高,午后冲顶更依赖辐射和风向切换。",
|
||||
label: isEnglish(locale) ? "Station representativeness" : "站点代表性",
|
||||
text: isEnglish(locale)
|
||||
? "When settlement station is not near city core, perceived temperature and settlement value may diverge."
|
||||
: "结算站与城市核心区存在一定距离时,体感温度和结算温度可能分离,评估时应优先以结算站观测为准。",
|
||||
});
|
||||
}
|
||||
|
||||
if (nearbyCount >= 4) {
|
||||
drivers.push({
|
||||
label: "局地差异",
|
||||
text: "周边可用站点较多,说明地形、城区热岛或下垫面差异可能明显,结算站与城区体感需要分开看。",
|
||||
label: isEnglish(locale) ? "Local heterogeneity" : "局地差异",
|
||||
text: isEnglish(locale)
|
||||
? "More nearby stations suggest terrain/urban-heat heterogeneity; settlement station and downtown sensation should be evaluated separately."
|
||||
: "周边可用站点较多,说明地形、城区热岛或下垫面差异可能明显,结算站与城区体感需要分开评估。",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
export type Locale = "zh-CN" | "en-US";
|
||||
|
||||
type MessageParams = Record<string, string | number>;
|
||||
|
||||
const DEFAULT_LOCALE: Locale = "zh-CN";
|
||||
export const LOCALE_STORAGE_KEY = "polyweather.locale";
|
||||
|
||||
const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
"zh-CN": {
|
||||
"header.subtitle": "天气衍生品智能分析",
|
||||
"header.info": "技术说明",
|
||||
"header.infoAria": "查看系统技术说明",
|
||||
"header.live": "实时",
|
||||
"header.refreshAria": "刷新所有数据",
|
||||
"header.langAria": "切换语言",
|
||||
"header.langZh": "中文",
|
||||
"header.langEn": "EN",
|
||||
|
||||
"sidebar.title": "监控城市",
|
||||
"sidebar.peakAt": "峰值 @ {time}",
|
||||
|
||||
"dashboard.loading": "正在获取气象数据,请稍候...",
|
||||
|
||||
"detail.closeAria": "关闭城市详情面板",
|
||||
"detail.waitSelect": "等待选择城市",
|
||||
"detail.todayAnalysis": "今日日内分析",
|
||||
"detail.history": "历史对账",
|
||||
"detail.loading": "正在加载城市详情...",
|
||||
"detail.emptyHint": "从左侧城市列表选择一个城市查看详情。",
|
||||
"detail.sceneryAlt": "{city} 风景照",
|
||||
"detail.sceneryTitle": "城市风景与微气候",
|
||||
"detail.sceneryFallback":
|
||||
"当前没有匹配到风景图,可从下方城市档案查看站点与观测结构。",
|
||||
"detail.profile": "城市档案",
|
||||
"detail.todayMiniTrend": "今日日内走势(简版)",
|
||||
"detail.chartLegendEmpty": "暂无小时级实测或预测曲线。",
|
||||
|
||||
"forecast.title": "多日预报",
|
||||
"forecast.empty": "暂无多日预报",
|
||||
"forecast.today": "今天",
|
||||
|
||||
"guide.title": "📎 PolyWeather 系统技术说明",
|
||||
"guide.closeAria": "关闭技术说明",
|
||||
"guide.footer":
|
||||
"数据源以 Aviation Weather / METAR、Turkish MGM、Open-Meteo、weather.gov 为主,部分城市补充 Meteoblue。",
|
||||
|
||||
"history.title": "📊 历史准确率对账 - {city}",
|
||||
"history.closeAria": "关闭历史对账",
|
||||
"history.loading": "正在获取历史数据...",
|
||||
"history.error": "获取历史信息失败",
|
||||
"history.empty": "近 15 天暂无该城市历史数据",
|
||||
"history.hitRate": "DEB 结算胜率 (WU)",
|
||||
"history.mae": "DEB MAE",
|
||||
"history.sample": "近 15 天已结算样本",
|
||||
"history.sampleDays": "{count} 天",
|
||||
|
||||
"future.todayTitle": "{city} · 今日日内分析",
|
||||
"future.dateTitle": "{city} · {date} 未来日期分析",
|
||||
"future.closeTodayAria": "关闭今日日内分析",
|
||||
"future.closeDateAria": "关闭未来日期分析",
|
||||
"future.currentObs": "当前实测",
|
||||
"future.currentWeather": "当前天气",
|
||||
"future.wuRef": "WU 结算参考",
|
||||
"future.sunrise": "日出时间",
|
||||
"future.sunset": "日落时间",
|
||||
"future.sunshine": "日照时长",
|
||||
"future.todayForecastHigh": "今日预报高温",
|
||||
"future.targetForecast": "目标日预报",
|
||||
"future.deb": "DEB 预测",
|
||||
"future.mu": "动态分布中心",
|
||||
"future.score": "趋势评分",
|
||||
"future.todayTempTrend": "今日温度走势",
|
||||
"future.targetTempTrend": "目标日小时走势",
|
||||
"future.probability": "结算概率分布",
|
||||
"future.models": "多模型预报",
|
||||
"future.structureToday": "今日日内结构信号",
|
||||
"future.structureDate": "未来 6-48 小时趋势",
|
||||
"future.judgement": "判断",
|
||||
"future.confidence": "置信度",
|
||||
"future.maxPrecip": "最大降水概率",
|
||||
"future.ai": "AI 深度分析",
|
||||
"future.noAi": "暂无 AI 分析,当前以结构化气象与模型数据为主。",
|
||||
"future.weatherGov": "weather.gov 文本",
|
||||
"future.risk": "结算与偏差风险",
|
||||
"future.climate": "当地气候主要受什么影响",
|
||||
"future.chartLegendEmpty": "暂无机场报文或小时级实测数据",
|
||||
|
||||
"confidence.high": "高",
|
||||
"confidence.medium": "中",
|
||||
"confidence.low": "低",
|
||||
|
||||
"section.todayTempTrend": "今日温度走势",
|
||||
"section.chartEmpty": "暂无小时级数据",
|
||||
"section.probability": "结算概率分布",
|
||||
"section.mu": "动态分布中心 μ = {value}{unit}",
|
||||
"section.noProb": "暂无概率数据",
|
||||
"section.models": "多模型预报",
|
||||
"section.noModels": "暂无多模型预报",
|
||||
"section.ai": "AI 深度分析",
|
||||
"section.aiEmpty": "暂无 AI 分析,当前以结构化气象与模型数据为主。",
|
||||
"section.risk": "数据偏差风险",
|
||||
"section.noRiskProfile": "暂无风险档案",
|
||||
"section.airport": "机场",
|
||||
"section.distance": "距离",
|
||||
"section.note": "注意",
|
||||
|
||||
"common.na": "--",
|
||||
},
|
||||
"en-US": {
|
||||
"header.subtitle": "Weather Derivatives Intelligence",
|
||||
"header.info": "Tech Notes",
|
||||
"header.infoAria": "Open system technical notes",
|
||||
"header.live": "LIVE",
|
||||
"header.refreshAria": "Refresh all data",
|
||||
"header.langAria": "Switch language",
|
||||
"header.langZh": "中文",
|
||||
"header.langEn": "EN",
|
||||
|
||||
"sidebar.title": "Monitored Cities",
|
||||
"sidebar.peakAt": "Peak @ {time}",
|
||||
|
||||
"dashboard.loading": "Loading weather data, please wait...",
|
||||
|
||||
"detail.closeAria": "Close city detail panel",
|
||||
"detail.waitSelect": "Waiting for city selection",
|
||||
"detail.todayAnalysis": "Today's Intraday",
|
||||
"detail.history": "History Reconciliation",
|
||||
"detail.loading": "Loading city details...",
|
||||
"detail.emptyHint": "Select a city from the left list to view details.",
|
||||
"detail.sceneryAlt": "{city} scenery",
|
||||
"detail.sceneryTitle": "City Scenery & Microclimate",
|
||||
"detail.sceneryFallback":
|
||||
"No scenery image matched. You can still review station and observation profile below.",
|
||||
"detail.profile": "City Profile",
|
||||
"detail.todayMiniTrend": "Today's Intraday Trend (Compact)",
|
||||
"detail.chartLegendEmpty": "No hourly observations or forecast curve available.",
|
||||
|
||||
"forecast.title": "Multi-day Forecast",
|
||||
"forecast.empty": "No multi-day forecast available",
|
||||
"forecast.today": "Today",
|
||||
|
||||
"guide.title": "📎 PolyWeather Technical Overview",
|
||||
"guide.closeAria": "Close technical overview",
|
||||
"guide.footer":
|
||||
"Primary data sources are Aviation Weather / METAR, Turkish MGM, Open-Meteo, and weather.gov, with Meteoblue added for selected cities.",
|
||||
|
||||
"history.title": "📊 Historical Reconciliation - {city}",
|
||||
"history.closeAria": "Close history reconciliation",
|
||||
"history.loading": "Loading historical data...",
|
||||
"history.error": "Failed to load historical data",
|
||||
"history.empty": "No historical records for this city in the last 15 days",
|
||||
"history.hitRate": "DEB Settlement Hit Rate (WU)",
|
||||
"history.mae": "DEB MAE",
|
||||
"history.sample": "Settled Samples (Last 15 Days)",
|
||||
"history.sampleDays": "{count} days",
|
||||
|
||||
"future.todayTitle": "{city} · Intraday Analysis",
|
||||
"future.dateTitle": "{city} · {date} Future-date Analysis",
|
||||
"future.closeTodayAria": "Close intraday analysis",
|
||||
"future.closeDateAria": "Close future-date analysis",
|
||||
"future.currentObs": "Current Observation",
|
||||
"future.currentWeather": "Current Weather",
|
||||
"future.wuRef": "WU Settlement Ref",
|
||||
"future.sunrise": "Sunrise",
|
||||
"future.sunset": "Sunset",
|
||||
"future.sunshine": "Sunshine Duration",
|
||||
"future.todayForecastHigh": "Today's Forecast High",
|
||||
"future.targetForecast": "Target-day Forecast",
|
||||
"future.deb": "DEB Forecast",
|
||||
"future.mu": "Dynamic Distribution Center",
|
||||
"future.score": "Trend Score",
|
||||
"future.todayTempTrend": "Today's Temperature Trend",
|
||||
"future.targetTempTrend": "Target-day Hourly Trend",
|
||||
"future.probability": "Settlement Probability Distribution",
|
||||
"future.models": "Multi-model Forecast",
|
||||
"future.structureToday": "Intraday Structural Signal",
|
||||
"future.structureDate": "6-48h Structural Trend",
|
||||
"future.judgement": "Judgement",
|
||||
"future.confidence": "Confidence",
|
||||
"future.maxPrecip": "Max Precip Probability",
|
||||
"future.ai": "AI Deep Analysis",
|
||||
"future.noAi": "No AI analysis available. Structured meteorological and model data are used as baseline.",
|
||||
"future.weatherGov": "weather.gov text",
|
||||
"future.risk": "Settlement & Deviation Risk",
|
||||
"future.climate": "What Mainly Drives Local Climate",
|
||||
"future.chartLegendEmpty": "No METAR bulletin or hourly observations available",
|
||||
|
||||
"confidence.high": "High",
|
||||
"confidence.medium": "Medium",
|
||||
"confidence.low": "Low",
|
||||
|
||||
"section.todayTempTrend": "Today's Temperature Trend",
|
||||
"section.chartEmpty": "No hourly data available",
|
||||
"section.probability": "Settlement Probability Distribution",
|
||||
"section.mu": "Dynamic center μ = {value}{unit}",
|
||||
"section.noProb": "No probability data available",
|
||||
"section.models": "Multi-model Forecast",
|
||||
"section.noModels": "No multi-model forecast available",
|
||||
"section.ai": "AI Deep Analysis",
|
||||
"section.aiEmpty": "No AI analysis available. Structured meteorological and model data are currently used.",
|
||||
"section.risk": "Data Deviation Risk",
|
||||
"section.noRiskProfile": "No risk profile available",
|
||||
"section.airport": "Airport",
|
||||
"section.distance": "Distance",
|
||||
"section.note": "Note",
|
||||
|
||||
"common.na": "--",
|
||||
},
|
||||
};
|
||||
|
||||
export function normalizeLocale(value?: string | null): Locale {
|
||||
if (!value) return DEFAULT_LOCALE;
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized.startsWith("en")) return "en-US";
|
||||
return "zh-CN";
|
||||
}
|
||||
|
||||
export function getInitialLocaleFromNavigator(): Locale {
|
||||
if (typeof window === "undefined") return DEFAULT_LOCALE;
|
||||
return normalizeLocale(window.navigator.language);
|
||||
}
|
||||
|
||||
export function formatMessage(
|
||||
locale: Locale,
|
||||
key: string,
|
||||
params?: MessageParams,
|
||||
): string {
|
||||
const template =
|
||||
MESSAGES[locale]?.[key] || MESSAGES[DEFAULT_LOCALE][key] || key;
|
||||
if (!params) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_, token) => {
|
||||
const value = params[token];
|
||||
return value == null ? "" : String(value);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from src.analysis.settlement_rounding import wu_round
|
||||
|
||||
# Cross-platform file locking
|
||||
import sys
|
||||
@@ -99,27 +100,39 @@ def update_daily_record(
|
||||
if date_str not in data[city_name]:
|
||||
data[city_name][date_str] = {}
|
||||
|
||||
# 避免无意义的频繁磁盘写入
|
||||
old_actual = data[city_name][date_str].get("actual_high")
|
||||
if (
|
||||
old_actual == actual_high
|
||||
and data[city_name][date_str].get("forecasts") == forecasts
|
||||
):
|
||||
return
|
||||
|
||||
data[city_name][date_str]["forecasts"] = forecasts
|
||||
data[city_name][date_str]["actual_high"] = actual_high
|
||||
if deb_prediction is not None:
|
||||
data[city_name][date_str]["deb_prediction"] = deb_prediction
|
||||
if mu is not None:
|
||||
data[city_name][date_str]["mu"] = round(mu, 2)
|
||||
compact_probs = None
|
||||
if probabilities is not None:
|
||||
# Store compact: [{"v": 25, "p": 0.8}, ...]
|
||||
data[city_name][date_str]["prob_snapshot"] = [
|
||||
compact_probs = [
|
||||
{"v": p["value"], "p": p["probability"]}
|
||||
for p in probabilities[:4]
|
||||
]
|
||||
|
||||
# 避免无意义的频繁磁盘写入
|
||||
existing = data[city_name][date_str]
|
||||
old_actual = existing.get("actual_high")
|
||||
old_deb = existing.get("deb_prediction")
|
||||
old_mu = existing.get("mu")
|
||||
old_probs = existing.get("prob_snapshot")
|
||||
next_mu = round(mu, 2) if mu is not None else None
|
||||
if (
|
||||
old_actual == actual_high
|
||||
and existing.get("forecasts") == forecasts
|
||||
and (deb_prediction is None or old_deb == deb_prediction)
|
||||
and (mu is None or old_mu == next_mu)
|
||||
and (compact_probs is None or old_probs == compact_probs)
|
||||
):
|
||||
return
|
||||
|
||||
existing["forecasts"] = forecasts
|
||||
existing["actual_high"] = actual_high
|
||||
if deb_prediction is not None:
|
||||
existing["deb_prediction"] = deb_prediction
|
||||
if mu is not None:
|
||||
existing["mu"] = next_mu
|
||||
if probabilities is not None:
|
||||
existing["prob_snapshot"] = compact_probs
|
||||
|
||||
# 自动清理:只保留最近 14 天的记录(DEB 只用 7 天,14 天留足余量)
|
||||
cutoff = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d")
|
||||
for city in list(data.keys()):
|
||||
@@ -173,7 +186,12 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
|
||||
|
||||
for model in current_forecasts.keys():
|
||||
if model in past_forecasts and past_forecasts[model] is not None:
|
||||
errors[model].append(abs(past_forecasts[model] - actual))
|
||||
try:
|
||||
pv = float(past_forecasts[model])
|
||||
av = float(actual)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
errors[model].append(abs(pv - av))
|
||||
|
||||
days_used += 1
|
||||
if days_used >= lookback_days:
|
||||
@@ -182,6 +200,8 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
|
||||
# 如果有效历史天数 < 2 天,还是使用等权
|
||||
if days_used < 2:
|
||||
valid_vals = [v for v in current_forecasts.values() if v is not None]
|
||||
if not valid_vals:
|
||||
return None, f"暂无有效模型数据(由于仅{days_used}天历史)"
|
||||
avg = sum(valid_vals) / len(valid_vals)
|
||||
return round(avg, 1), f"等权平均(由于仅{days_used}天历史)"
|
||||
|
||||
@@ -263,8 +283,8 @@ def get_deb_accuracy(city_name):
|
||||
continue
|
||||
|
||||
total += 1
|
||||
deb_wu = round(deb_pred)
|
||||
actual_wu = round(actual)
|
||||
deb_wu = wu_round(deb_pred)
|
||||
actual_wu = wu_round(actual)
|
||||
if deb_wu == actual_wu:
|
||||
hits += 1
|
||||
errors.append(abs(deb_pred - actual))
|
||||
@@ -328,13 +348,13 @@ def get_mu_accuracy(city_name):
|
||||
|
||||
total += 1
|
||||
mu_errors.append(abs(mu_val - actual))
|
||||
if round(mu_val) == round(actual):
|
||||
if wu_round(mu_val) == wu_round(actual):
|
||||
mu_hits += 1
|
||||
|
||||
# Brier Score from probability snapshot
|
||||
prob_snap = record.get("prob_snapshot", [])
|
||||
if prob_snap:
|
||||
actual_wu = round(actual)
|
||||
actual_wu = wu_round(actual)
|
||||
bs = 0.0
|
||||
for entry in prob_snap:
|
||||
predicted_p = entry.get("p", 0)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import math
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
Number = Union[int, float]
|
||||
|
||||
|
||||
def wu_round(value: Optional[Number]) -> Optional[int]:
|
||||
"""
|
||||
WU 结算口径四舍五入(0.5 一律进位):
|
||||
- 正数: floor(x + 0.5)
|
||||
- 负数: ceil(x - 0.5)
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
x = float(value)
|
||||
if x >= 0:
|
||||
return int(math.floor(x + 0.5))
|
||||
return int(math.ceil(x - 0.5))
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.analysis.deb_algorithm import (
|
||||
get_deb_accuracy,
|
||||
update_daily_record,
|
||||
)
|
||||
from src.analysis.settlement_rounding import wu_round
|
||||
from src.data_collection.city_risk_profiles import get_city_risk_profile
|
||||
|
||||
|
||||
@@ -348,7 +349,7 @@ def analyze_weather_trend(
|
||||
ai_features.append(f"🎲 数学概率分布:{prob_str}")
|
||||
|
||||
elif is_dead_market:
|
||||
settled_wu = round(max_so_far) if max_so_far is not None else 0
|
||||
settled_wu = wu_round(max_so_far) if max_so_far is not None else 0
|
||||
dead_msg = f"🎲 <b>结算预测</b>:已锁定 {settled_wu}{temp_symbol} (死盘确认)"
|
||||
insights.append(dead_msg)
|
||||
ai_features.append("🎲 状态: 确认死盘,结算已无悬念。")
|
||||
@@ -375,7 +376,7 @@ def analyze_weather_trend(
|
||||
|
||||
# === Settlement boundary ===
|
||||
if max_so_far is not None:
|
||||
settled = round(max_so_far)
|
||||
settled = wu_round(max_so_far)
|
||||
fractional = max_so_far - int(max_so_far)
|
||||
dist_to_boundary = abs(fractional - 0.5)
|
||||
if dist_to_boundary <= 0.3:
|
||||
@@ -437,7 +438,7 @@ def analyze_weather_trend(
|
||||
ai_features.append(f"🌡️ 当前实测温度: {cur_temp}{temp_symbol}。")
|
||||
if max_so_far is not None:
|
||||
ai_features.append(
|
||||
f"🏔️ 今日实测最高温: {max_so_far}{temp_symbol} (WU结算={round(max_so_far)}{temp_symbol})。"
|
||||
f"🏔️ 今日实测最高温: {max_so_far}{temp_symbol} (WU结算={wu_round(max_so_far)}{temp_symbol})。"
|
||||
)
|
||||
if city_name:
|
||||
_profile = get_city_risk_profile(city_name)
|
||||
@@ -486,7 +487,7 @@ def analyze_weather_trend(
|
||||
for t, p in sorted_probs[:4]
|
||||
]
|
||||
elif is_dead_market and max_so_far is not None:
|
||||
_prob_list = [{"value": round(max_so_far), "probability": 1.0}]
|
||||
_prob_list = [{"value": wu_round(max_so_far), "probability": 1.0}]
|
||||
|
||||
update_daily_record(
|
||||
city_name,
|
||||
@@ -524,7 +525,7 @@ def analyze_weather_trend(
|
||||
"forecast_miss_deg": forecast_miss_deg,
|
||||
"max_so_far": max_so_far,
|
||||
"cur_temp": cur_temp,
|
||||
"wu_settle": round(max_so_far) if max_so_far is not None else None,
|
||||
"wu_settle": wu_round(max_so_far) if max_so_far is not None else None,
|
||||
}
|
||||
display_str = "\n".join(insights) if insights else ""
|
||||
return display_str, "\n".join(ai_features), structured
|
||||
@@ -543,12 +544,12 @@ def calculate_prob_distribution(
|
||||
# 0.5 * (1 + erf( (x-m)/(s*sqrt(2)) ))
|
||||
return 0.5 * (1 + math.erf((x - m) / (sigma * math.sqrt(2))))
|
||||
|
||||
min_possible_wu = round(max_so_far) if max_so_far is not None else -999
|
||||
min_possible_wu = wu_round(max_so_far) if max_so_far is not None else -999
|
||||
probs = {}
|
||||
|
||||
# Range: mu +/- 3 sigma or at least +/- 2 degrees
|
||||
search_range = max(2, int(sigma * 2.5))
|
||||
target_mu = round(mu)
|
||||
target_mu = wu_round(mu)
|
||||
|
||||
for n in range(target_mu - search_range, target_mu + search_range + 1):
|
||||
if n < min_possible_wu:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,19 @@ class WeatherDataCollector:
|
||||
"munich": ["EDDM", "EDMO", "EDJA"],
|
||||
}
|
||||
|
||||
# Meteoblue 仅在增益最大的城市启用(减少配额消耗与冗余请求)
|
||||
METEOBLUE_PRIORITY_CITIES = {
|
||||
"london",
|
||||
"paris",
|
||||
"seoul",
|
||||
"toronto",
|
||||
"buenos aires",
|
||||
"wellington",
|
||||
"lucknow",
|
||||
"sao paulo",
|
||||
"munich",
|
||||
}
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
weather_cfg = config.get("weather", {})
|
||||
@@ -1503,14 +1516,15 @@ class WeatherDataCollector:
|
||||
# 获取时区偏移以过滤 METAR
|
||||
utc_offset = open_meteo.get("utc_offset", 0)
|
||||
|
||||
mb_data = self.fetch_from_meteoblue(
|
||||
lat,
|
||||
lon,
|
||||
timezone_name=open_meteo.get("timezone", "UTC"),
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
)
|
||||
if mb_data:
|
||||
results["meteoblue"] = mb_data
|
||||
if city_lower in self.METEOBLUE_PRIORITY_CITIES:
|
||||
mb_data = self.fetch_from_meteoblue(
|
||||
lat,
|
||||
lon,
|
||||
timezone_name=open_meteo.get("timezone", "UTC"),
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
)
|
||||
if mb_data:
|
||||
results["meteoblue"] = mb_data
|
||||
|
||||
# 对美国城市,额外获取 NWS 高精预报
|
||||
if use_fahrenheit:
|
||||
|
||||
+33
-34
@@ -28,7 +28,9 @@ from loguru import logger
|
||||
from src.utils.config_loader import load_config
|
||||
from src.data_collection.weather_sources import WeatherDataCollector
|
||||
from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES
|
||||
from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer
|
||||
from src.analysis.deb_algorithm import calculate_dynamic_weights, get_deb_accuracy
|
||||
from src.analysis.settlement_rounding import wu_round
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Setup
|
||||
@@ -49,6 +51,7 @@ app.add_middleware(
|
||||
|
||||
_config = load_config()
|
||||
_weather = WeatherDataCollector(_config)
|
||||
_market_layer = PolymarketReadOnlyLayer()
|
||||
|
||||
from src.data_collection.city_registry import CITY_REGISTRY, ALIASES
|
||||
|
||||
@@ -129,7 +132,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
if " " in max_temp_time:
|
||||
max_temp_time = max_temp_time.split(" ")[1][:5]
|
||||
|
||||
wu_settle = round(max_so_far) if max_so_far is not None else None
|
||||
wu_settle = wu_round(max_so_far) if max_so_far is not None else None
|
||||
|
||||
# Observation time → local
|
||||
obs_time_str = ""
|
||||
@@ -672,9 +675,30 @@ def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _build_city_detail_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _build_city_detail_payload(
|
||||
data: Dict[str, Any],
|
||||
market_slug: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
distribution = data.get("probabilities", {}).get("distribution", []) or []
|
||||
primary_bucket = distribution[0] if distribution else None
|
||||
model_probability = (
|
||||
(primary_bucket.get("probability") / 100.0)
|
||||
if isinstance(primary_bucket, dict) and primary_bucket.get("probability") is not None
|
||||
else None
|
||||
)
|
||||
fallback_sparkline = [
|
||||
p.get("probability", 0)
|
||||
for p in distribution[:8]
|
||||
if isinstance(p, dict)
|
||||
]
|
||||
market_scan = _market_layer.build_market_scan(
|
||||
city=data.get("name"),
|
||||
target_date=data.get("local_date"),
|
||||
temperature_bucket=primary_bucket if isinstance(primary_bucket, dict) else None,
|
||||
model_probability=model_probability,
|
||||
fallback_sparkline=fallback_sparkline,
|
||||
forced_market_slug=market_slug,
|
||||
)
|
||||
return {
|
||||
"city": data.get("name"),
|
||||
"fetched_at": data.get("updated_at"),
|
||||
@@ -718,36 +742,7 @@ def _build_city_detail_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
},
|
||||
"models": data.get("multi_model") or {},
|
||||
"probabilities": data.get("probabilities") or {"mu": None, "distribution": []},
|
||||
"market_scan": {
|
||||
"available": False,
|
||||
"reason": "Market layer is not available on the current backend build.",
|
||||
"primary_market": None,
|
||||
"selected_date": data.get("local_date"),
|
||||
"selected_condition_id": None,
|
||||
"selected_slug": None,
|
||||
"temperature_bucket": primary_bucket,
|
||||
"model_probability": (
|
||||
(primary_bucket.get("probability") / 100.0)
|
||||
if isinstance(primary_bucket, dict) and primary_bucket.get("probability") is not None
|
||||
else None
|
||||
),
|
||||
"market_price": None,
|
||||
"edge_percent": None,
|
||||
"signal_label": "MONITOR",
|
||||
"confidence": "low",
|
||||
"yes_token": None,
|
||||
"no_token": None,
|
||||
"yes_buy": None,
|
||||
"yes_sell": None,
|
||||
"no_buy": None,
|
||||
"no_sell": None,
|
||||
"last_trade_price": None,
|
||||
"liquidity": None,
|
||||
"volume": None,
|
||||
"sparkline": [p.get("probability", 0) for p in distribution[:8] if isinstance(p, dict)],
|
||||
"recent_trades": [],
|
||||
"websocket": {},
|
||||
},
|
||||
"market_scan": market_scan,
|
||||
"risk": data.get("risk"),
|
||||
"ai_analysis": data.get("ai_analysis") or "",
|
||||
"errors": {},
|
||||
@@ -797,10 +792,14 @@ async def city_summary(name: str, force_refresh: bool = False):
|
||||
|
||||
|
||||
@app.get("/api/city/{name}/detail")
|
||||
async def city_detail_aggregate(name: str, force_refresh: bool = False):
|
||||
async def city_detail_aggregate(
|
||||
name: str,
|
||||
force_refresh: bool = False,
|
||||
market_slug: Optional[str] = None,
|
||||
):
|
||||
city = _normalize_city_or_404(name)
|
||||
data = _analyze(city, force_refresh=force_refresh)
|
||||
return _build_city_detail_payload(data)
|
||||
return _build_city_detail_payload(data, market_slug=market_slug)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Entrypoint
|
||||
|
||||
Reference in New Issue
Block a user