Add RP5 forecast scraping support
This commit is contained in:
@@ -22,7 +22,7 @@ const TELEGRAM_GROUP_URL = String(
|
||||
const FAQ_ITEMS = [
|
||||
{
|
||||
q: "Pro 包含哪些功能?",
|
||||
a: "开通后可解锁:今日日内深度分析(含高温时段)、历史对账 + 未来日期分析、全平台智能气象推送。",
|
||||
a: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、历史对账 + 未来日期分析、全平台智能气象推送。",
|
||||
},
|
||||
{
|
||||
q: "当前订阅价格是多少?",
|
||||
|
||||
@@ -60,7 +60,9 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
|
||||
borderWidth: 0,
|
||||
data: chartData.datasets.metarPoints,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
|
||||
label:
|
||||
chartData.observationLabel ||
|
||||
(locale === "en-US" ? "METAR Observation" : "METAR 实况"),
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 3.8,
|
||||
showLine: false,
|
||||
|
||||
@@ -248,7 +248,9 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.metarPoints,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
|
||||
label:
|
||||
todayChartData.observationLabel ||
|
||||
(locale === "en-US" ? "METAR Observation" : "METAR 实况"),
|
||||
order: 0,
|
||||
pointHoverRadius: 7,
|
||||
pointRadius: 5,
|
||||
|
||||
@@ -19,6 +19,10 @@ function HistoryChart() {
|
||||
const hasMgm =
|
||||
store.selectedCity === "ankara" &&
|
||||
summary.mgms.some((value) => value != null);
|
||||
const hasBestBaseline =
|
||||
Boolean(summary.bestModelName) &&
|
||||
summary.bestModelName !== "MGM" &&
|
||||
summary.bestModelSeries.some((value) => value != null);
|
||||
|
||||
const canvasRef = useChart(() => {
|
||||
const datasets: NonNullable<
|
||||
@@ -62,6 +66,23 @@ function HistoryChart() {
|
||||
});
|
||||
}
|
||||
|
||||
if (hasBestBaseline) {
|
||||
datasets.push({
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#60a5fa",
|
||||
borderDash: [4, 3],
|
||||
borderWidth: 2,
|
||||
data: summary.bestModelSeries,
|
||||
label:
|
||||
locale === "en-US"
|
||||
? `Best Baseline (${summary.bestModelName})`
|
||||
: `最佳单模型 (${summary.bestModelName})`,
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 4,
|
||||
tension: 0.2,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets,
|
||||
@@ -114,7 +135,7 @@ function HistoryChart() {
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}, [hasMgm, summary, locale]);
|
||||
}, [hasBestBaseline, hasMgm, summary, locale]);
|
||||
|
||||
if (!summary.recentData.length) return null;
|
||||
|
||||
@@ -195,17 +216,37 @@ export function HistoryModal() {
|
||||
) : (
|
||||
<>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">{t("history.hitRate")}</span>
|
||||
<span className="label">{t("history.debHitRate")}</span>
|
||||
<span className="val">
|
||||
{summary.hitRate != null ? `${summary.hitRate}%` : "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">{t("history.mae")}</span>
|
||||
<span className="label">{t("history.debMae")}</span>
|
||||
<span className="val">
|
||||
{summary.debMae != null ? `${summary.debMae}°` : "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">{t("history.bestModelMae")}</span>
|
||||
<span className="val">
|
||||
{summary.bestModelMae != null
|
||||
? `${summary.bestModelMae}°${
|
||||
summary.bestModelName
|
||||
? ` (${summary.bestModelName})`
|
||||
: ""
|
||||
}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">{t("history.debVsBest")}</span>
|
||||
<span className="val">
|
||||
{summary.debWinRateVsBest != null
|
||||
? `${summary.debWinRateVsBest}% (${summary.debWinDaysVsBest}/${summary.debVsBestComparableDays})`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">{t("history.sample")}</span>
|
||||
<span className="val">
|
||||
|
||||
@@ -287,7 +287,9 @@ export function TemperatureChart() {
|
||||
borderWidth: 0,
|
||||
data: chartData.datasets.metarPoints,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
|
||||
label:
|
||||
chartData.observationLabel ||
|
||||
(locale === "en-US" ? "METAR Observation" : "METAR 实况"),
|
||||
order: 0,
|
||||
pointHoverRadius: 7,
|
||||
pointRadius: 5,
|
||||
|
||||
@@ -56,12 +56,12 @@ type UnlockProOverlayProps = {
|
||||
|
||||
const FEATURES = {
|
||||
"zh-CN": [
|
||||
"今日日内深度分析(含高温时段)",
|
||||
"今日日内机场报文规则分析(含高温时段)",
|
||||
"历史对账 + 未来日期分析",
|
||||
"全平台智能气象推送",
|
||||
],
|
||||
"en-US": [
|
||||
"Intraday deep analysis with peak-time window",
|
||||
"Intraday METAR rule-based analysis with peak-time window",
|
||||
"Historical reconciliation + future-date analysis",
|
||||
"Cross-platform alerts",
|
||||
],
|
||||
|
||||
@@ -287,6 +287,10 @@ export interface CityDetail {
|
||||
time?: string;
|
||||
temp?: number | null;
|
||||
}>;
|
||||
settlement_today_obs?: Array<{
|
||||
time?: string;
|
||||
temp?: number | null;
|
||||
}>;
|
||||
trend?: TrendInfo;
|
||||
peak?: PeakInfo;
|
||||
ai_analysis?: string | AiAnalysisStructured | null;
|
||||
@@ -300,7 +304,9 @@ export interface HistoryPoint {
|
||||
date: string;
|
||||
actual: number | null;
|
||||
deb: number | null;
|
||||
mu?: number | null;
|
||||
mgm?: number | null;
|
||||
forecasts?: Record<string, number | null>;
|
||||
}
|
||||
|
||||
export interface LoadingState {
|
||||
|
||||
+115
-12
@@ -30,6 +30,24 @@ function isEnglish(locale: Locale) {
|
||||
return locale === "en-US";
|
||||
}
|
||||
|
||||
function getObservationSourceCode(detail: CityDetail): string {
|
||||
return String(detail.current?.settlement_source || "metar")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function getObservationSourceTag(detail: CityDetail): string {
|
||||
const label = String(detail.current?.settlement_source_label || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (label) return label;
|
||||
const code = getObservationSourceCode(detail);
|
||||
if (code === "hko") return "HKO";
|
||||
if (code === "cwa") return "CWA";
|
||||
if (code === "mgm") return "MGM";
|
||||
return "METAR";
|
||||
}
|
||||
|
||||
function normalizeCloudSummary(
|
||||
cloudDesc: string | null | undefined,
|
||||
locale: Locale,
|
||||
@@ -119,6 +137,7 @@ export function getWeatherSummary(detail: CityDetail, locale: Locale = "zh-CN")
|
||||
export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
|
||||
const current = detail.current || {};
|
||||
const parts: string[] = [];
|
||||
const sourceTag = getObservationSourceTag(detail);
|
||||
|
||||
if (current.obs_time) {
|
||||
const ageText =
|
||||
@@ -127,7 +146,7 @@ export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
|
||||
? ` (${current.obs_age_min} min ago)`
|
||||
: `(${current.obs_age_min} 分钟前)`
|
||||
: "";
|
||||
parts.push(`✈️ METAR ${current.obs_time}${ageText}`);
|
||||
parts.push(`✈️ ${sourceTag} ${current.obs_time}${ageText}`);
|
||||
}
|
||||
|
||||
if (current.wx_desc) {
|
||||
@@ -209,12 +228,18 @@ export function getTemperatureChartData(
|
||||
currentIndex < 0 || index >= currentIndex ? temp : null,
|
||||
);
|
||||
|
||||
const metarPoints = new Array(times.length).fill(null);
|
||||
const metarSource = detail.metar_today_obs?.length
|
||||
? detail.metar_today_obs
|
||||
: detail.trend?.recent || [];
|
||||
const observationTag = getObservationSourceTag(detail);
|
||||
const observationCode = getObservationSourceCode(detail);
|
||||
const settlementSource =
|
||||
observationCode === "hko" || observationCode === "cwa";
|
||||
const observationSource = settlementSource
|
||||
? detail.settlement_today_obs || []
|
||||
: detail.metar_today_obs?.length
|
||||
? detail.metar_today_obs
|
||||
: detail.trend?.recent || [];
|
||||
|
||||
metarSource.forEach((item) => {
|
||||
const metarPoints = new Array(times.length).fill(null);
|
||||
observationSource.forEach((item) => {
|
||||
const parts = String(item.time || "").split(":");
|
||||
let hour = Number.parseInt(parts[0], 10);
|
||||
const minute = Number.parseInt(parts[1] || "0", 10);
|
||||
@@ -286,13 +311,17 @@ export function getTemperatureChartData(
|
||||
: "已使用 MGM 小时预报替代 DEB 曲线",
|
||||
);
|
||||
}
|
||||
if (detail.trend?.recent?.length) {
|
||||
const recentText = [...detail.trend.recent]
|
||||
if ((detail.trend?.recent?.length || 0) > 0 || observationSource.length > 0) {
|
||||
const recentData =
|
||||
observationSource.length > 0
|
||||
? [...observationSource]
|
||||
: [...(detail.trend?.recent || [])];
|
||||
const recentText = recentData
|
||||
.slice(0, 4)
|
||||
.reverse()
|
||||
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
|
||||
.join(" -> ");
|
||||
legendParts.push(`METAR: ${recentText}`);
|
||||
legendParts.push(`${observationTag}: ${recentText}`);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -306,6 +335,9 @@ export function getTemperatureChartData(
|
||||
offset,
|
||||
temps,
|
||||
},
|
||||
observationLabel: isEnglish(locale)
|
||||
? `${observationTag} Observation`
|
||||
: `${observationTag} 实况`,
|
||||
legendText: legendParts.join(" | "),
|
||||
max,
|
||||
min,
|
||||
@@ -973,6 +1005,13 @@ export function getHistorySummary(
|
||||
history: HistoryPoint[],
|
||||
cityLocalDate?: string | null,
|
||||
) {
|
||||
const toFinite = (value: unknown): number | null => {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
};
|
||||
const isExcludedModel = (name: string) =>
|
||||
String(name || "").toLowerCase().includes("meteoblue");
|
||||
|
||||
const cutoff = new Date();
|
||||
cutoff.setHours(0, 0, 0, 0);
|
||||
cutoff.setDate(cutoff.getDate() - 14);
|
||||
@@ -992,15 +1031,65 @@ export function getHistorySummary(
|
||||
|
||||
let hits = 0;
|
||||
const debErrors: number[] = [];
|
||||
const modelErrors: Record<string, number[]> = {};
|
||||
|
||||
settledData.forEach((row) => {
|
||||
if (row.actual != null && row.deb != null) {
|
||||
debErrors.push(Math.abs(row.actual - row.deb));
|
||||
if (wuRound(row.actual) === wuRound(row.deb)) {
|
||||
const actual = toFinite(row.actual);
|
||||
const deb = toFinite(row.deb);
|
||||
if (actual != null && deb != null) {
|
||||
debErrors.push(Math.abs(actual - deb));
|
||||
if (wuRound(actual) === wuRound(deb)) {
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const forecasts = row.forecasts || {};
|
||||
Object.entries(forecasts).forEach(([modelName, modelValue]) => {
|
||||
if (isExcludedModel(modelName)) return;
|
||||
const mv = toFinite(modelValue);
|
||||
if (actual == null || mv == null) return;
|
||||
if (!modelErrors[modelName]) {
|
||||
modelErrors[modelName] = [];
|
||||
}
|
||||
modelErrors[modelName].push(Math.abs(actual - mv));
|
||||
});
|
||||
});
|
||||
|
||||
const modelMaeList = Object.entries(modelErrors)
|
||||
.map(([name, errors]) => ({
|
||||
mae:
|
||||
errors.length > 0
|
||||
? errors.reduce((sum, value) => sum + value, 0) / errors.length
|
||||
: Number.POSITIVE_INFINITY,
|
||||
model: name,
|
||||
sampleCount: errors.length,
|
||||
}))
|
||||
.filter((row) => Number.isFinite(row.mae) && row.sampleCount > 0)
|
||||
.sort((a, b) => a.mae - b.mae);
|
||||
|
||||
const primaryModelMaeList = modelMaeList.filter((row) => row.sampleCount >= 2);
|
||||
const bestModel = (primaryModelMaeList[0] || modelMaeList[0]) ?? null;
|
||||
const bestModelName = bestModel?.model || null;
|
||||
const bestModelMae = bestModel ? Number(bestModel.mae.toFixed(1)) : null;
|
||||
const bestModelSeries = recentData.map((row) =>
|
||||
bestModelName ? toFinite(row.forecasts?.[bestModelName]) : null,
|
||||
);
|
||||
|
||||
let debWinDaysVsBest = 0;
|
||||
let debVsBestComparableDays = 0;
|
||||
if (bestModelName) {
|
||||
settledData.forEach((row) => {
|
||||
const actual = toFinite(row.actual);
|
||||
const deb = toFinite(row.deb);
|
||||
const bestModelVal = toFinite(row.forecasts?.[bestModelName]);
|
||||
if (actual == null || deb == null || bestModelVal == null) return;
|
||||
debVsBestComparableDays += 1;
|
||||
if (Math.abs(deb - actual) <= Math.abs(bestModelVal - actual)) {
|
||||
debWinDaysVsBest += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
dates: recentData.map((row) => row.date),
|
||||
debMae: debErrors.length
|
||||
@@ -1011,6 +1100,20 @@ export function getHistorySummary(
|
||||
)
|
||||
: null,
|
||||
debs: recentData.map((row) => row.deb),
|
||||
bestModelName,
|
||||
bestModelMae,
|
||||
bestModelSeries,
|
||||
modelMaeRanks: modelMaeList.map((row) => ({
|
||||
model: row.model,
|
||||
mae: Number(row.mae.toFixed(1)),
|
||||
sampleCount: row.sampleCount,
|
||||
})),
|
||||
debWinDaysVsBest,
|
||||
debVsBestComparableDays,
|
||||
debWinRateVsBest:
|
||||
debVsBestComparableDays > 0
|
||||
? Number(((debWinDaysVsBest / debVsBestComparableDays) * 100).toFixed(0))
|
||||
: null,
|
||||
hitRate: debErrors.length
|
||||
? Number(((hits / debErrors.length) * 100).toFixed(0))
|
||||
: null,
|
||||
|
||||
+18
-8
@@ -59,6 +59,11 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
"history.empty": "近 15 天暂无该城市历史数据",
|
||||
"history.hitRate": "DEB 结算胜率 (WU)",
|
||||
"history.mae": "DEB MAE",
|
||||
"history.debHitRate": "DEB 结算胜率 (WU)",
|
||||
"history.debMae": "DEB MAE",
|
||||
"history.muMae": "μ MAE",
|
||||
"history.bestModelMae": "最佳单模型 MAE",
|
||||
"history.debVsBest": "DEB 优于最佳模型",
|
||||
"history.sample": "近 15 天已结算样本",
|
||||
"history.sampleDays": "{count} 天",
|
||||
|
||||
@@ -86,8 +91,8 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
"future.judgement": "判断",
|
||||
"future.confidence": "置信度",
|
||||
"future.maxPrecip": "最大降水概率",
|
||||
"future.ai": "AI 深度分析",
|
||||
"future.noAi": "暂无 AI 分析,当前以结构化气象与模型数据为主。",
|
||||
"future.ai": "机场报文解读",
|
||||
"future.noAi": "暂无机场报文解读,当前以结构化气象与模型数据为主。",
|
||||
"future.weatherGov": "weather.gov 文本",
|
||||
"future.risk": "结算与偏差风险",
|
||||
"future.climate": "当地气候主要受什么影响",
|
||||
@@ -104,8 +109,8 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
"section.noProb": "暂无概率数据",
|
||||
"section.models": "多模型预报",
|
||||
"section.noModels": "暂无多模型预报",
|
||||
"section.ai": "AI 深度分析",
|
||||
"section.aiEmpty": "暂无 AI 分析,当前以结构化气象与模型数据为主。",
|
||||
"section.ai": "机场报文解读",
|
||||
"section.aiEmpty": "暂无机场报文解读,当前以结构化气象与模型数据为主。",
|
||||
"section.risk": "数据偏差风险",
|
||||
"section.noRiskProfile": "暂无风险档案",
|
||||
"section.airport": "机场",
|
||||
@@ -214,6 +219,11 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
"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.debHitRate": "DEB Settlement Hit Rate (WU)",
|
||||
"history.debMae": "DEB MAE",
|
||||
"history.muMae": "μ MAE",
|
||||
"history.bestModelMae": "Best Single-model MAE",
|
||||
"history.debVsBest": "DEB vs Best Model",
|
||||
"history.sample": "Settled Samples (Last 15 Days)",
|
||||
"history.sampleDays": "{count} days",
|
||||
|
||||
@@ -241,9 +251,9 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
"future.judgement": "Judgement",
|
||||
"future.confidence": "Confidence",
|
||||
"future.maxPrecip": "Max Precip Probability",
|
||||
"future.ai": "AI Deep Analysis",
|
||||
"future.ai": "Airport METAR Narrative",
|
||||
"future.noAi":
|
||||
"No AI analysis available. Structured meteorological and model data are used as baseline.",
|
||||
"No airport bulletin narrative is 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",
|
||||
@@ -261,9 +271,9 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
"section.noProb": "No probability data available",
|
||||
"section.models": "Multi-model Forecast",
|
||||
"section.noModels": "No multi-model forecast available",
|
||||
"section.ai": "AI Deep Analysis",
|
||||
"section.ai": "Airport METAR Narrative",
|
||||
"section.aiEmpty":
|
||||
"No AI analysis available. Structured meteorological and model data are currently used.",
|
||||
"No airport bulletin narrative is available. Structured meteorological data are currently used.",
|
||||
"section.risk": "Data Deviation Risk",
|
||||
"section.noRiskProfile": "No risk profile available",
|
||||
"section.airport": "Airport",
|
||||
|
||||
@@ -343,6 +343,7 @@ export interface CityDetail {
|
||||
timeseries: {
|
||||
metar_recent_obs: any[];
|
||||
metar_today_obs: any[];
|
||||
settlement_today_obs?: any[];
|
||||
hourly: any;
|
||||
mgm_hourly: any[];
|
||||
forecast_daily: any[];
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
</section>
|
||||
|
||||
<section class="ai-section">
|
||||
<h3>AI 深度分析</h3>
|
||||
<h3>机场报文解读</h3>
|
||||
<div id="aiAnalysis" class="ai-box">
|
||||
<span class="ai-placeholder">点击城市后加载...</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user