1924 lines
74 KiB
TypeScript
1924 lines
74 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Cloud,
|
||
CloudFog,
|
||
CloudLightning,
|
||
CloudRain,
|
||
CloudSnow,
|
||
CloudSun,
|
||
Search,
|
||
Sun,
|
||
Wind,
|
||
} from "lucide-react";
|
||
|
||
import type { ChartConfiguration } from "chart.js";
|
||
import clsx from "clsx";
|
||
import { CSSProperties, useEffect, useMemo, useState } from "react";
|
||
import { useChart } from "@/hooks/useChart";
|
||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||
import { useI18n } from "@/hooks/useI18n";
|
||
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
|
||
import {
|
||
ModelForecast,
|
||
ProbabilityDistribution,
|
||
} from "@/components/dashboard/PanelSections";
|
||
import {
|
||
getFutureModalView,
|
||
getModelView,
|
||
getProbabilityView,
|
||
getTodayPaceView,
|
||
getTemperatureChartData,
|
||
getWeatherSummary,
|
||
} from "@/lib/dashboard-utils";
|
||
import type { IntradayMeteorologySignal } from "@/lib/dashboard-types";
|
||
|
||
function normalizeMarketValue(value?: number | null) {
|
||
if (value == null) return null;
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return null;
|
||
if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
|
||
return Math.max(0, Math.min(1, numeric));
|
||
}
|
||
|
||
function formatMinuteAxisLabel(value: number) {
|
||
if (!Number.isFinite(value)) return "";
|
||
const total = Math.max(0, Math.round(value));
|
||
const hour = Math.floor(total / 60) % 24;
|
||
const minute = total % 60;
|
||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||
}
|
||
|
||
function WeatherIcon({ emoji, size = 32 }: { emoji: string; size?: number }) {
|
||
if (emoji === "☀️") return <Sun size={size} color="#facc15" />;
|
||
if (emoji === "⛅" || emoji === "🌤️")
|
||
return <CloudSun size={size} color="#38bdf8" />;
|
||
if (emoji === "☁️") return <Cloud size={size} color="#94a3b8" />;
|
||
if (emoji === "🌧️" || emoji === "🌦️")
|
||
return <CloudRain size={size} color="#60a5fa" />;
|
||
if (emoji === "⛈️") return <CloudLightning size={size} color="#c084fc" />;
|
||
if (emoji === "❄️" || emoji === "🌨️")
|
||
return <CloudSnow size={size} color="#7dd3fc" />;
|
||
if (emoji === "🌫️") return <CloudFog size={size} color="#a1a1aa" />;
|
||
if (emoji === "💨") return <Wind size={size} color="#cbd5e1" />;
|
||
return <Search size={size} color="#64748b" />;
|
||
}
|
||
|
||
function formatMarketPercent(value?: number | null) {
|
||
const normalized = normalizeMarketValue(value);
|
||
if (normalized == null) return "--";
|
||
return `${(normalized * 100).toFixed(1)}%`;
|
||
}
|
||
|
||
function formatBucketLabel(
|
||
bucket?: {
|
||
label?: string | null;
|
||
bucket?: string | null;
|
||
range?: string | null;
|
||
value?: number | null;
|
||
temp?: number | null;
|
||
} | null,
|
||
) {
|
||
if (!bucket) return "--";
|
||
const direct =
|
||
String(bucket.label || "").trim() ||
|
||
String(bucket.bucket || "").trim() ||
|
||
String(bucket.range || "").trim();
|
||
if (direct) {
|
||
let str = direct.toUpperCase().replace(/\s+/g, "");
|
||
str = str.replace(/°?C($|\+|-)/g, "℃$1");
|
||
if (!str.includes("℃") && /[0-9]/.test(str)) {
|
||
str += "℃";
|
||
}
|
||
return str;
|
||
}
|
||
|
||
const temp = Number(bucket.value ?? bucket.temp);
|
||
if (Number.isFinite(temp)) {
|
||
return `${Math.round(temp)}℃`;
|
||
}
|
||
return "--";
|
||
}
|
||
|
||
function parseBucketBoundaries(
|
||
bucket?: {
|
||
label?: string | null;
|
||
bucket?: string | null;
|
||
range?: string | null;
|
||
value?: number | null;
|
||
temp?: number | null;
|
||
} | null,
|
||
) {
|
||
if (!bucket) return null;
|
||
const raw =
|
||
String(bucket.label || "").trim() ||
|
||
String(bucket.bucket || "").trim() ||
|
||
String(bucket.range || "").trim();
|
||
if (!raw) return null;
|
||
const numbers = Array.from(raw.matchAll(/-?\d+(?:\.\d+)?/g)).map((match) =>
|
||
Number(match[0]),
|
||
);
|
||
if (!numbers.length) return null;
|
||
if (raw.includes("+")) {
|
||
return {
|
||
lower: numbers[0] ?? null,
|
||
upper: null as number | null,
|
||
boundaryLabel: `${numbers[0]}°C`,
|
||
};
|
||
}
|
||
if (numbers.length >= 2) {
|
||
return {
|
||
lower: numbers[0],
|
||
upper: numbers[1],
|
||
boundaryLabel: null as string | null,
|
||
};
|
||
}
|
||
return {
|
||
lower: numbers[0],
|
||
upper: null as number | null,
|
||
boundaryLabel: `${numbers[0]}°C`,
|
||
};
|
||
}
|
||
|
||
function clamp(value: number, min: number, max: number) {
|
||
return Math.min(Math.max(value, min), max);
|
||
}
|
||
|
||
function parseClockMinutes(value?: string | null) {
|
||
const text = String(value || "").trim();
|
||
const match = text.match(/^(\d{1,2}):(\d{2})$/);
|
||
if (!match) return null;
|
||
const hours = Number(match[1]);
|
||
const minutes = Number(match[2]);
|
||
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
|
||
return hours * 60 + minutes;
|
||
}
|
||
|
||
function parseLeadingNumber(value?: string | number | null) {
|
||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||
const text = String(value || "").trim();
|
||
const match = text.match(/[-+]?\d+(?:\.\d+)?/);
|
||
if (!match) return null;
|
||
const numeric = Number(match[0]);
|
||
return Number.isFinite(numeric) ? numeric : null;
|
||
}
|
||
|
||
function parsePercentFromText(value?: string | number | null) {
|
||
if (typeof value === "number" && Number.isFinite(value)) {
|
||
return clamp(value, 0, 100);
|
||
}
|
||
const text = String(value || "").trim();
|
||
const percentMatch = text.match(/([-+]?\d+(?:\.\d+)?)\s*%/);
|
||
if (percentMatch) {
|
||
const numeric = Number(percentMatch[1]);
|
||
return Number.isFinite(numeric) ? clamp(numeric, 0, 100) : null;
|
||
}
|
||
return parseLeadingNumber(text);
|
||
}
|
||
|
||
function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
|
||
const normalized = String(value || "").trim().toLowerCase();
|
||
if (normalized === "high") return locale === "en-US" ? "High" : "高";
|
||
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
|
||
if (normalized === "low") return locale === "en-US" ? "Low" : "低";
|
||
return locale === "en-US" ? "Pending" : "待确认";
|
||
}
|
||
|
||
function formatSignalDirection(value?: string | null, locale = "zh-CN") {
|
||
const normalized = String(value || "").trim().toLowerCase();
|
||
if (normalized === "support") return locale === "en-US" ? "Support" : "支持升温";
|
||
if (normalized === "suppress") return locale === "en-US" ? "Suppress" : "压制峰值";
|
||
return locale === "en-US" ? "Neutral" : "中性";
|
||
}
|
||
|
||
function formatSignalStrength(value?: string | null, locale = "zh-CN") {
|
||
const normalized = String(value || "").trim().toLowerCase();
|
||
if (normalized === "strong") return locale === "en-US" ? "Strong" : "强";
|
||
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
|
||
return locale === "en-US" ? "Weak" : "弱";
|
||
}
|
||
|
||
function signalTone(signal?: IntradayMeteorologySignal | null) {
|
||
const direction = String(signal?.direction || "").trim().toLowerCase();
|
||
if (direction === "support") return "cyan";
|
||
if (direction === "suppress") return "amber";
|
||
return "blue";
|
||
}
|
||
|
||
function localizedText(
|
||
locale: string,
|
||
primary?: string | null,
|
||
english?: string | null,
|
||
) {
|
||
const en = String(english || "").trim();
|
||
const value = String(primary || "").trim();
|
||
if (locale === "en-US" && en) return en;
|
||
return value || en;
|
||
}
|
||
|
||
function localizedList(
|
||
locale: string,
|
||
primary?: string[] | null,
|
||
english?: string[] | null,
|
||
) {
|
||
const en = Array.isArray(english)
|
||
? english.filter((item) => String(item || "").trim())
|
||
: [];
|
||
const value = Array.isArray(primary)
|
||
? primary.filter((item) => String(item || "").trim())
|
||
: [];
|
||
if (locale === "en-US" && en.length) return en;
|
||
return value.length ? value : en;
|
||
}
|
||
|
||
function getTrendMetricVisual(metric: {
|
||
label?: string;
|
||
value?: string;
|
||
tone?: string;
|
||
}) {
|
||
const label = String(metric.label || "").toLowerCase();
|
||
const value = String(metric.value || "");
|
||
const numeric = parseLeadingNumber(value);
|
||
|
||
if (label.includes("降水") || label.includes("precip")) {
|
||
const precipPercent = parsePercentFromText(value);
|
||
if (precipPercent == null) return null;
|
||
return {
|
||
mode: "fill" as const,
|
||
percent: precipPercent,
|
||
tone: "cold" as const,
|
||
};
|
||
}
|
||
|
||
if (numeric == null) return null;
|
||
|
||
if (label.includes("温度") || label.includes("temp")) {
|
||
return {
|
||
mode: "center" as const,
|
||
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
|
||
tone: numeric >= 0 ? "warm" as const : "cold" as const,
|
||
};
|
||
}
|
||
|
||
if (label.includes("露点") || label.includes("dew")) {
|
||
return {
|
||
mode: "center" as const,
|
||
percent: clamp(50 + (numeric / 3) * 50, 0, 100),
|
||
tone: numeric >= 0 ? "warm" as const : "cold" as const,
|
||
};
|
||
}
|
||
|
||
if (label.includes("气压") || label.includes("pressure")) {
|
||
return {
|
||
mode: "center" as const,
|
||
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
|
||
tone: numeric >= 0 ? "warm" as const : "cold" as const,
|
||
};
|
||
}
|
||
|
||
if (label.includes("云量") || label.includes("cloud")) {
|
||
return {
|
||
mode: "center" as const,
|
||
percent: clamp(50 + (numeric / 40) * 50, 0, 100),
|
||
tone: numeric >= 0 ? "cold" as const : "warm" as const,
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
|
||
const store = useDashboardStore();
|
||
const { locale, t } = useI18n();
|
||
const detail = store.selectedDetail;
|
||
const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
|
||
const isToday = detail ? dateStr === detail.local_date : false;
|
||
const todayChartData = useMemo(
|
||
() => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
|
||
[detail, isToday, locale],
|
||
);
|
||
|
||
const canvasRef = useChart(() => {
|
||
if (!detail || !view) {
|
||
return {
|
||
data: { datasets: [], labels: [] },
|
||
type: "line",
|
||
} satisfies ChartConfiguration<"line">;
|
||
}
|
||
|
||
if (isToday && todayChartData) {
|
||
const datasets: NonNullable<
|
||
ChartConfiguration<"line">["data"]
|
||
>["datasets"] = [];
|
||
|
||
if (todayChartData.datasets.hasMgmHourly) {
|
||
datasets.push({
|
||
backgroundColor: "rgba(234, 179, 8, 0.05)",
|
||
borderColor: "rgba(234, 179, 8, 0.8)",
|
||
borderWidth: 2,
|
||
data: todayChartData.datasets.mgmHourlySeries,
|
||
fill: false,
|
||
label: locale === "en-US" ? "MGM Forecast" : "MGM 预测",
|
||
parsing: false,
|
||
pointHoverRadius: 6,
|
||
pointRadius: 3,
|
||
spanGaps: true,
|
||
tension: 0.3,
|
||
});
|
||
} else {
|
||
datasets.push({
|
||
backgroundColor: "rgba(52, 211, 153, 0.05)",
|
||
borderColor: "rgba(52, 211, 153, 0.6)",
|
||
borderWidth: 1.5,
|
||
data: todayChartData.datasets.debPastSeries,
|
||
fill: true,
|
||
label: locale === "en-US" ? "DEB Forecast" : "DEB 预测",
|
||
parsing: false,
|
||
pointHoverRadius: 3,
|
||
pointRadius: 0,
|
||
tension: 0.3,
|
||
});
|
||
datasets.push({
|
||
borderColor: "rgba(52, 211, 153, 0.35)",
|
||
borderDash: [5, 3],
|
||
borderWidth: 1.5,
|
||
data: todayChartData.datasets.debFutureSeries,
|
||
fill: false,
|
||
label: locale === "en-US" ? "DEB Forecast" : "DEB 预测",
|
||
parsing: false,
|
||
pointRadius: 0,
|
||
tension: 0.3,
|
||
});
|
||
}
|
||
|
||
datasets.push({
|
||
backgroundColor: "#22d3ee",
|
||
borderColor: "#22d3ee",
|
||
borderWidth: 0,
|
||
data: todayChartData.datasets.metarSeries,
|
||
fill: false,
|
||
label:
|
||
todayChartData.observationLabel ||
|
||
(locale === "en-US" ? "Observation" : "观测实况"),
|
||
order: 0,
|
||
parsing: false,
|
||
pointHoverRadius: 7,
|
||
pointRadius: 5,
|
||
showLine: false,
|
||
});
|
||
|
||
if (todayChartData.datasets.airportMetarSeries?.length > 0) {
|
||
datasets.push({
|
||
backgroundColor: "#60a5fa",
|
||
borderColor: "#60a5fa",
|
||
borderWidth: 1,
|
||
data: todayChartData.datasets.airportMetarSeries,
|
||
fill: false,
|
||
label:
|
||
locale === "en-US" ? "Airport METAR" : "机场 METAR",
|
||
order: 0,
|
||
parsing: false,
|
||
pointHoverRadius: 6,
|
||
pointRadius: 4,
|
||
showLine: false,
|
||
});
|
||
}
|
||
|
||
if (todayChartData.datasets.mgmSeries?.length > 0) {
|
||
datasets.push({
|
||
backgroundColor: "#facc15",
|
||
borderColor: "#facc15",
|
||
borderWidth: 0,
|
||
data: todayChartData.datasets.mgmSeries,
|
||
fill: false,
|
||
label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
|
||
order: -1,
|
||
parsing: false,
|
||
pointHoverRadius: 9,
|
||
pointRadius: 7,
|
||
showLine: false,
|
||
});
|
||
}
|
||
|
||
if (
|
||
!todayChartData.datasets.hasMgmHourly &&
|
||
Math.abs(todayChartData.datasets.offset) > 0.3
|
||
) {
|
||
datasets.push({
|
||
borderColor: "rgba(99, 102, 241, 0.2)",
|
||
borderDash: [2, 4],
|
||
borderWidth: 1,
|
||
data: todayChartData.datasets.tempsSeries,
|
||
fill: false,
|
||
label: locale === "en-US" ? "OM Raw" : "OM 原始",
|
||
parsing: false,
|
||
pointRadius: 0,
|
||
tension: 0.3,
|
||
});
|
||
}
|
||
if ((todayChartData.tafMarkers || []).length > 0) {
|
||
datasets.push({
|
||
backgroundColor: "#f59e0b",
|
||
borderColor: "#f59e0b",
|
||
borderWidth: 0,
|
||
data: todayChartData.datasets.tafCurrentMarkerSeries,
|
||
fill: false,
|
||
label: locale === "en-US" ? "Current TAF" : "当前 TAF",
|
||
order: -3,
|
||
parsing: false,
|
||
pointHoverRadius: 8,
|
||
pointRadius: 6,
|
||
showLine: false,
|
||
});
|
||
datasets.push({
|
||
backgroundColor: "rgba(250, 204, 21, 0.72)",
|
||
borderColor: "rgba(250, 204, 21, 0.72)",
|
||
borderWidth: 0,
|
||
data: todayChartData.datasets.tafPeakWindowMarkerSeries,
|
||
fill: false,
|
||
label: locale === "en-US" ? "Peak-window TAF" : "峰值窗口 TAF",
|
||
order: -2,
|
||
parsing: false,
|
||
pointHoverRadius: 7,
|
||
pointRadius: 4,
|
||
showLine: false,
|
||
});
|
||
datasets.push({
|
||
backgroundColor: "#f59e0b",
|
||
borderColor: "#f59e0b",
|
||
borderWidth: 0,
|
||
data: todayChartData.datasets.tafMarkerSeries,
|
||
fill: false,
|
||
label: locale === "en-US" ? "TAF Timing" : "TAF 时段",
|
||
order: -4,
|
||
parsing: false,
|
||
pointHoverRadius: 0,
|
||
pointRadius: 0,
|
||
showLine: false,
|
||
});
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
datasets,
|
||
labels: [],
|
||
},
|
||
options: {
|
||
interaction: { intersect: false, mode: "nearest" },
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
labels: {
|
||
color: "#94a3b8",
|
||
filter: (legendItem, chartData) => {
|
||
const text = String(legendItem.text || "");
|
||
if (!text) return false;
|
||
if (text === "TAF Timing" || text === "TAF 时段") return false;
|
||
if (!text.includes("DEB")) return true;
|
||
|
||
const firstDebIndex = (chartData.datasets || []).findIndex(
|
||
(dataset) => String(dataset.label || "").includes("DEB"),
|
||
);
|
||
return legendItem.datasetIndex === firstDebIndex;
|
||
},
|
||
font: { family: "Inter", size: 11 },
|
||
},
|
||
},
|
||
tooltip: {
|
||
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||
borderColor: "rgba(34, 211, 238, 0.2)",
|
||
borderWidth: 1,
|
||
callbacks: {
|
||
title: (items) => {
|
||
const rawX = items?.[0]?.parsed?.x;
|
||
return rawX != null ? formatMinuteAxisLabel(Number(rawX)) : "";
|
||
},
|
||
label: (ctx) => {
|
||
const label = String(ctx.dataset.label || "");
|
||
const raw = ctx.raw as
|
||
| { marker?: { summary?: string; markerType?: string; displayType?: string; isCurrent?: boolean; isPeakWindow?: boolean } }
|
||
| undefined;
|
||
if (
|
||
label === "TAF Timing" ||
|
||
label === "TAF 时段" ||
|
||
label === "Current TAF" ||
|
||
label === "当前 TAF" ||
|
||
label === "Peak-window TAF" ||
|
||
label === "峰值窗口 TAF"
|
||
) {
|
||
const marker = raw?.marker;
|
||
if (!marker) return label;
|
||
const markerType = String(marker.markerType || "");
|
||
const displayType = String(
|
||
marker.displayType || marker.markerType || "",
|
||
);
|
||
const summary = String(marker.summary || "");
|
||
const prefix =
|
||
marker.isCurrent && marker.isPeakWindow
|
||
? locale === "en-US"
|
||
? "Current / peak-window TAF"
|
||
: "当前 / 峰值窗口 TAF"
|
||
: marker.isCurrent
|
||
? locale === "en-US"
|
||
? "Current TAF"
|
||
: "当前 TAF"
|
||
: marker.isPeakWindow
|
||
? locale === "en-US"
|
||
? "Peak-window TAF"
|
||
: "峰值窗口 TAF"
|
||
: label;
|
||
return `${prefix}: ${
|
||
markerType ? summary.replace(markerType, displayType) : summary
|
||
}`;
|
||
}
|
||
const value = ctx.parsed.y;
|
||
if (value == null) return label;
|
||
return `${label}: ${value.toFixed(1)}${detail.temp_symbol || "°C"}`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
responsive: true,
|
||
scales: {
|
||
x: {
|
||
max: todayChartData.xMax,
|
||
min: todayChartData.xMin,
|
||
grid: { color: "rgba(255,255,255,0.04)" },
|
||
type: "linear",
|
||
ticks: {
|
||
callback: (value) => {
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) return "";
|
||
const minutes = Math.round(num);
|
||
if (
|
||
minutes !== todayChartData.xMin &&
|
||
minutes !== todayChartData.xMax &&
|
||
minutes % 120 !== 0
|
||
) {
|
||
return "";
|
||
}
|
||
return formatMinuteAxisLabel(minutes);
|
||
},
|
||
color: "#64748b",
|
||
font: { family: "Inter", size: 10 },
|
||
maxRotation: 0,
|
||
},
|
||
},
|
||
y: {
|
||
grid: { color: "rgba(255,255,255,0.04)" },
|
||
max: todayChartData.max,
|
||
min: todayChartData.min,
|
||
ticks: {
|
||
callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
|
||
color: "#64748b",
|
||
font: { family: "Inter", size: 10 },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
type: "line",
|
||
} satisfies ChartConfiguration<"line">;
|
||
}
|
||
|
||
const labels = view.slice.map((point) => point.label);
|
||
const unit = detail.temp_symbol || "°C";
|
||
|
||
return {
|
||
data: {
|
||
datasets: [
|
||
{
|
||
backgroundColor: "rgba(34, 211, 238, 0.08)",
|
||
borderColor: "#22d3ee",
|
||
data: view.slice.map((point) => point.temp),
|
||
fill: false,
|
||
label:
|
||
locale === "en-US" ? "Open-Meteo Temperature" : "Open-Meteo 温度",
|
||
pointRadius: 2,
|
||
tension: 0.28,
|
||
},
|
||
{
|
||
backgroundColor: "transparent",
|
||
borderColor: "#a78bfa",
|
||
borderDash: [5, 4],
|
||
data: view.slice.map((point) => point.dewPoint),
|
||
fill: false,
|
||
label: locale === "en-US" ? "Dew Point" : "露点",
|
||
pointRadius: 0,
|
||
tension: 0.24,
|
||
},
|
||
],
|
||
labels,
|
||
},
|
||
options: {
|
||
interaction: { intersect: false, mode: "index" },
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
labels: {
|
||
color: "#94a3b8",
|
||
font: { family: "Inter", size: 11 },
|
||
},
|
||
},
|
||
tooltip: {
|
||
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||
borderColor: "rgba(34, 211, 238, 0.2)",
|
||
borderWidth: 1,
|
||
callbacks: {
|
||
label: (ctx) =>
|
||
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${unit}`,
|
||
},
|
||
},
|
||
},
|
||
responsive: true,
|
||
scales: {
|
||
x: {
|
||
grid: { color: "rgba(255,255,255,0.04)" },
|
||
ticks: {
|
||
color: "#64748b",
|
||
font: { family: "Inter", size: 10 },
|
||
maxRotation: 0,
|
||
},
|
||
},
|
||
y: {
|
||
grid: { color: "rgba(255,255,255,0.04)" },
|
||
ticks: {
|
||
callback: (value) => `${value}${unit}`,
|
||
color: "#64748b",
|
||
font: { family: "Inter", size: 10 },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
type: "line",
|
||
} satisfies ChartConfiguration<"line">;
|
||
}, [detail, isToday, locale, todayChartData, view]);
|
||
|
||
return (
|
||
<>
|
||
<div className="history-chart-wrapper future-chart-wrapper">
|
||
<canvas ref={canvasRef} />
|
||
</div>
|
||
{isToday && (
|
||
<div className="chart-legend">
|
||
{todayChartData?.legendText || t("future.chartLegendEmpty")}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export function FutureForecastModal() {
|
||
const store = useDashboardStore();
|
||
const { locale, t } = useI18n();
|
||
const detail = store.selectedDetail;
|
||
const dateStr = store.futureModalDate;
|
||
const isPro = store.proAccess.subscriptionActive;
|
||
const isProLoading = store.proAccess.loading;
|
||
const [showDeferredTodaySections, setShowDeferredTodaySections] = useState(false);
|
||
|
||
if (!detail || !dateStr) return null;
|
||
|
||
useEffect(() => {
|
||
setShowDeferredTodaySections(false);
|
||
if (typeof window === "undefined") {
|
||
setShowDeferredTodaySections(true);
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||
let idleId: number | null = null;
|
||
const reveal = () => {
|
||
if (!cancelled) {
|
||
setShowDeferredTodaySections(true);
|
||
}
|
||
};
|
||
|
||
if ("requestIdleCallback" in window) {
|
||
idleId = window.requestIdleCallback(reveal, { timeout: 600 });
|
||
} else {
|
||
timeoutId = setTimeout(reveal, 120);
|
||
}
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
if (idleId != null && "cancelIdleCallback" in window) {
|
||
window.cancelIdleCallback(idleId);
|
||
}
|
||
if (timeoutId != null) {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
};
|
||
}, [dateStr, detail]);
|
||
|
||
const isToday = dateStr === detail.local_date;
|
||
const detailDepth = detail.detail_depth || "full";
|
||
const isFullDetailReady = detailDepth === "full";
|
||
const isStructureSyncing = store.loadingState.futureDeep || !isFullDetailReady;
|
||
const isAnyLayerSyncing = isStructureSyncing;
|
||
const view = getFutureModalView(detail, dateStr, locale);
|
||
const scorePosition = `${50 + view.front.score / 2}%`;
|
||
const barStyle = {
|
||
"--score-position": scorePosition,
|
||
} as CSSProperties & { "--score-position": string };
|
||
const weatherSummary = getWeatherSummary(detail, locale);
|
||
const paceView = useMemo(
|
||
() =>
|
||
isToday && showDeferredTodaySections
|
||
? getTodayPaceView(detail, locale)
|
||
: null,
|
||
[detail, isToday, locale, showDeferredTodaySections],
|
||
);
|
||
const probabilityView = useMemo(
|
||
() => getProbabilityView(detail, dateStr),
|
||
[dateStr, detail],
|
||
);
|
||
const modelView = useMemo(() => getModelView(detail, dateStr), [dateStr, detail]);
|
||
const topProbabilityBucket = useMemo(() => {
|
||
const buckets = Array.isArray(probabilityView?.probabilities)
|
||
? probabilityView.probabilities
|
||
: [];
|
||
return [...buckets]
|
||
.filter((bucket) => Number.isFinite(Number(bucket?.probability)))
|
||
.sort((a, b) => Number(b?.probability) - Number(a?.probability))[0];
|
||
}, [probabilityView]);
|
||
const modelSpreadView = useMemo(() => {
|
||
const values = Object.values(modelView?.models || {})
|
||
.map((value) => Number(value))
|
||
.filter((value) => Number.isFinite(value));
|
||
if (!values.length) return null;
|
||
const min = Math.min(...values);
|
||
const max = Math.max(...values);
|
||
const spread = max - min;
|
||
return {
|
||
count: values.length,
|
||
max,
|
||
min,
|
||
spread,
|
||
};
|
||
}, [modelView]);
|
||
const boundaryRiskView = useMemo(() => {
|
||
if (!showDeferredTodaySections) return null;
|
||
if (!isToday || !paceView) return null;
|
||
const selectedBucket = topProbabilityBucket || null;
|
||
const bounds = parseBucketBoundaries(selectedBucket);
|
||
if (!bounds) return null;
|
||
const projected =
|
||
paceView.paceAdjustedHigh ??
|
||
(detail.deb?.prediction != null ? Number(detail.deb.prediction) : null);
|
||
if (projected == null || !Number.isFinite(projected)) return null;
|
||
|
||
const distances = [bounds.lower, bounds.upper]
|
||
.filter((value): value is number => value != null && Number.isFinite(value))
|
||
.map((value) => ({
|
||
boundary: value,
|
||
gap: Math.abs(projected - value),
|
||
}))
|
||
.sort((a, b) => a.gap - b.gap);
|
||
if (!distances.length) return null;
|
||
|
||
const nearest = distances[0];
|
||
const tone =
|
||
nearest.gap <= 0.4 ? "amber" : nearest.gap <= 0.8 ? "blue" : "cyan";
|
||
const status =
|
||
nearest.gap <= 0.4
|
||
? locale === "en-US"
|
||
? "High boundary risk"
|
||
: "边界风险高"
|
||
: nearest.gap <= 0.8
|
||
? locale === "en-US"
|
||
? "Watch boundary"
|
||
: "边界需观察"
|
||
: locale === "en-US"
|
||
? "Boundary buffer"
|
||
: "边界缓冲";
|
||
const note =
|
||
locale === "en-US"
|
||
? `${projected.toFixed(1)}${detail.temp_symbol} is ${nearest.gap.toFixed(1)}${detail.temp_symbol} from the nearest boundary ${nearest.boundary.toFixed(1)}°C.`
|
||
: `${projected.toFixed(1)}${detail.temp_symbol} 距最近边界 ${nearest.boundary.toFixed(1)}°C 还有 ${nearest.gap.toFixed(1)}${detail.temp_symbol}。`;
|
||
return {
|
||
label: locale === "en-US" ? "Boundary risk" : "边界风险",
|
||
note,
|
||
status,
|
||
tone,
|
||
value: `${nearest.gap.toFixed(1)}${detail.temp_symbol}`,
|
||
};
|
||
}, [detail.deb?.prediction, detail.temp_symbol, isToday, locale, paceView, showDeferredTodaySections, topProbabilityBucket]);
|
||
const peakWindowStateView = useMemo(() => {
|
||
if (!showDeferredTodaySections) return null;
|
||
if (!isToday || !paceView) return null;
|
||
const firstHour = Number(detail.peak?.first_h);
|
||
const lastHour = Number(detail.peak?.last_h);
|
||
if (
|
||
!Number.isFinite(firstHour) ||
|
||
!Number.isFinite(lastHour) ||
|
||
firstHour < 0 ||
|
||
lastHour < firstHour
|
||
) {
|
||
return null;
|
||
}
|
||
const currentMinutes = parseClockMinutes(detail.local_time);
|
||
const startMinutes = firstHour * 60;
|
||
const endMinutes = (lastHour + 1) * 60;
|
||
let status = locale === "en-US" ? "Awaiting peak" : "未进入峰值";
|
||
let tone: "amber" | "blue" | "cyan" = "blue";
|
||
if (currentMinutes != null && currentMinutes >= endMinutes) {
|
||
status = locale === "en-US" ? "Past peak" : "已过峰值";
|
||
tone = "cyan";
|
||
} else if (currentMinutes != null && currentMinutes >= startMinutes) {
|
||
status = locale === "en-US" ? "Peak window live" : "峰值窗口进行中";
|
||
tone = "amber";
|
||
}
|
||
const note =
|
||
locale === "en-US"
|
||
? `Primary peak window ${paceView.peakWindowText}.`
|
||
: `核心峰值窗口 ${paceView.peakWindowText}。`;
|
||
return {
|
||
label: locale === "en-US" ? "Peak window" : "峰值窗口状态",
|
||
note,
|
||
status,
|
||
tone,
|
||
value: paceView.peakWindowText,
|
||
};
|
||
}, [detail.local_time, detail.peak?.first_h, detail.peak?.last_h, isToday, locale, paceView, showDeferredTodaySections]);
|
||
const networkLeadView = useMemo(() => {
|
||
if (!showDeferredTodaySections) return null;
|
||
if (!isToday) return null;
|
||
const delta = Number(detail.airport_vs_network_delta);
|
||
const leadSignal = detail.network_lead_signal;
|
||
if (!Number.isFinite(delta)) return null;
|
||
const leaderLabel =
|
||
String(leadSignal?.leader_station_label || "").trim() ||
|
||
String(leadSignal?.leader_station_code || "").trim();
|
||
const absDelta = Math.abs(delta);
|
||
const status =
|
||
delta <= -0.4
|
||
? locale === "en-US"
|
||
? "Airport trailing"
|
||
: "机场落后"
|
||
: delta >= 0.4
|
||
? locale === "en-US"
|
||
? "Airport leading"
|
||
: "机场领先"
|
||
: locale === "en-US"
|
||
? "Tracking network"
|
||
: "与站网齐平";
|
||
const tone =
|
||
delta <= -0.4 ? "amber" : delta >= 0.4 ? "cyan" : "blue";
|
||
const note =
|
||
delta <= -0.4
|
||
? locale === "en-US"
|
||
? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} cooler than the nearby official network${leaderLabel ? `, led by ${leaderLabel}` : ""}.`
|
||
: `机场主站当前比周边官方站网低 ${absDelta.toFixed(1)}${detail.temp_symbol}${leaderLabel ? `,领先点位是 ${leaderLabel}` : ""}。`
|
||
: delta >= 0.4
|
||
? locale === "en-US"
|
||
? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} hotter than the nearby official network.`
|
||
: `机场主站当前比周边官方站网高 ${absDelta.toFixed(1)}${detail.temp_symbol}。`
|
||
: locale === "en-US"
|
||
? "Airport anchor and nearby official network are broadly aligned."
|
||
: "机场主站与周边官方站网当前大体齐平。";
|
||
return {
|
||
label: locale === "en-US" ? "Airport vs network" : "机场 vs 周边站",
|
||
note,
|
||
status,
|
||
tone,
|
||
value: `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`,
|
||
};
|
||
}, [detail.airport_vs_network_delta, detail.network_lead_signal, detail.temp_symbol, isToday, locale, showDeferredTodaySections]);
|
||
const isNoaaSettlement =
|
||
detail.current?.settlement_source === "noaa" ||
|
||
detail.current?.settlement_source_label === "NOAA";
|
||
const noaaStationCode = String(
|
||
detail.current?.station_code || detail.risk?.icao || "NOAA",
|
||
)
|
||
.trim()
|
||
.toUpperCase();
|
||
const noaaStationName =
|
||
String(detail.current?.station_name || "").trim() ||
|
||
String(detail.risk?.airport || "").trim() ||
|
||
noaaStationCode;
|
||
const hottestBucketLabel = formatBucketLabel(topProbabilityBucket);
|
||
const probabilitySummary = (() => {
|
||
if (!topProbabilityBucket) {
|
||
return locale === "en-US"
|
||
? "Probability mass is still too dispersed; avoid over-reading a single bracket."
|
||
: "当前概率还比较分散,不要只盯单一区间。";
|
||
}
|
||
const bucketLabel = formatBucketLabel(topProbabilityBucket);
|
||
const bucketProb = formatMarketPercent(topProbabilityBucket.probability);
|
||
return locale === "en-US"
|
||
? `Highest current hit probability is ${bucketLabel} at ${bucketProb}. Treat this as the base case, not the final settlement.`
|
||
: `当前命中概率最高的是 ${bucketLabel}(${bucketProb}),可把它当作基准情形,但不要直接等同于最终结算。`;
|
||
})();
|
||
const modelSummary = (() => {
|
||
if (!modelSpreadView) {
|
||
return locale === "en-US"
|
||
? "Model spread is unavailable right now."
|
||
: "当前拿不到可用的模型分歧。";
|
||
}
|
||
const modelEntries = Object.entries(modelView?.models || {}).filter(
|
||
([, value]) => value !== null && value !== undefined && Number.isFinite(Number(value)),
|
||
);
|
||
if (modelEntries.length === 1) {
|
||
const [singleModelName, singleModelValue] = modelEntries[0];
|
||
return locale === "en-US"
|
||
? `Only ${singleModelName} is available right now at ${Number(singleModelValue).toFixed(1)}${detail.temp_symbol}; multi-model spread is temporarily unavailable.`
|
||
: `当前只收到 ${singleModelName} ${Number(singleModelValue).toFixed(1)}${detail.temp_symbol},其他多模型暂未回传,所以这里先不判断模型分歧。`;
|
||
}
|
||
return locale === "en-US"
|
||
? `Model range runs from ${modelSpreadView.min.toFixed(1)}${detail.temp_symbol} to ${modelSpreadView.max.toFixed(1)}${detail.temp_symbol}; spread ${modelSpreadView.spread.toFixed(1)}${detail.temp_symbol}.`
|
||
: `当前模型区间在 ${modelSpreadView.min.toFixed(1)}${detail.temp_symbol} 到 ${modelSpreadView.max.toFixed(1)}${detail.temp_symbol},分歧 ${modelSpreadView.spread.toFixed(1)}${detail.temp_symbol}。`;
|
||
})();
|
||
const upperAirSignal = detail.vertical_profile_signal || {};
|
||
const tafSignal = detail.taf?.signal || {};
|
||
const upperAirCue = useMemo(() => {
|
||
if (!showDeferredTodaySections) return null;
|
||
if (!isToday || (!upperAirSignal.source && !tafSignal.available)) return null;
|
||
|
||
const setup = String(upperAirSignal.heating_setup || "neutral").toLowerCase();
|
||
const tafSuppression = String(
|
||
tafSignal.suppression_level || "low",
|
||
).toLowerCase();
|
||
const tafDisruption = String(
|
||
tafSignal.disruption_level || "low",
|
||
).toLowerCase();
|
||
const reasons: string[] = [];
|
||
let score = 0;
|
||
|
||
if (setup === "supportive") {
|
||
score += 2;
|
||
reasons.push(
|
||
locale === "en-US"
|
||
? "upper-air structure still supports daytime heating"
|
||
: "高空结构仍偏支持白天冲高",
|
||
);
|
||
} else if (setup === "suppressed") {
|
||
score -= 2;
|
||
reasons.push(
|
||
locale === "en-US"
|
||
? "upper-air structure still leans toward capping the peak"
|
||
: "高空结构更偏向压住峰值",
|
||
);
|
||
}
|
||
|
||
if (tafSuppression === "high") {
|
||
score -= 2;
|
||
reasons.push(
|
||
locale === "en-US"
|
||
? "TAF flags meaningful cloud/rain suppression near the peak window"
|
||
: "TAF 在峰值窗口提示云雨压温风险偏高",
|
||
);
|
||
} else if (tafSuppression === "medium") {
|
||
score -= 1;
|
||
reasons.push(
|
||
locale === "en-US"
|
||
? "TAF keeps some cloud/rain suppression risk on the table"
|
||
: "TAF 仍提示一定的云雨压温风险",
|
||
);
|
||
}
|
||
|
||
if (tafDisruption === "high") {
|
||
score -= 1;
|
||
reasons.push(
|
||
locale === "en-US"
|
||
? "TAF also suggests a noisier afternoon regime"
|
||
: "TAF 还提示午后扰动偏强",
|
||
);
|
||
} else if (tafDisruption === "medium") {
|
||
score -= 0.5;
|
||
reasons.push(
|
||
locale === "en-US"
|
||
? "TAF keeps some afternoon timing noise in play"
|
||
: "TAF 提示午后仍可能有时段性扰动",
|
||
);
|
||
}
|
||
|
||
if (score >= 1.5) {
|
||
return {
|
||
summary:
|
||
locale === "en-US"
|
||
? "The combined upper-air and TAF read still leans warmer. Do not fade lower buckets too early."
|
||
: "高空和 TAF 两层信号合并后仍偏暖侧,不宜过早做更低温区间。",
|
||
note:
|
||
locale === "en-US"
|
||
? `${reasons.slice(0, 2).join("; ")}.`
|
||
: `${reasons.slice(0, 2).join(";")}。`,
|
||
tone: "warm",
|
||
value: locale === "en-US" ? "Lean warmer" : "偏暖侧",
|
||
};
|
||
}
|
||
|
||
if (score <= -1.5) {
|
||
return {
|
||
summary:
|
||
locale === "en-US"
|
||
? "The combined upper-air and TAF read leans more defensive. Be more careful chasing higher buckets."
|
||
: "高空和 TAF 两层信号合并后更偏防守,追更高温区间要更谨慎。",
|
||
note:
|
||
locale === "en-US"
|
||
? `${reasons.slice(0, 2).join("; ")}.`
|
||
: `${reasons.slice(0, 2).join(";")}。`,
|
||
tone: "cold",
|
||
value: locale === "en-US" ? "Lean cautious" : "偏谨慎",
|
||
};
|
||
}
|
||
|
||
return {
|
||
summary:
|
||
locale === "en-US"
|
||
? "The combined upper-air and TAF read is mixed. Let surface structure decide before taking a side."
|
||
: "高空和 TAF 两层信号目前偏混合,先看近地面结构变化,不急着站边。",
|
||
note:
|
||
locale === "en-US"
|
||
? `${reasons.slice(0, 2).join("; ") || "No clean edge from the upper-air layer alone"}.`
|
||
: `${reasons.slice(0, 2).join(";") || "单看高空层还没有干净的交易边"}。`,
|
||
tone: "",
|
||
value: locale === "en-US" ? "Wait / confirm" : "先观察",
|
||
};
|
||
}, [
|
||
tafSignal.available,
|
||
tafSignal.disruption_level,
|
||
tafSignal.suppression_level,
|
||
isToday,
|
||
locale,
|
||
upperAirSignal.heating_setup,
|
||
upperAirSignal.source,
|
||
showDeferredTodaySections,
|
||
]);
|
||
const topObservedTemp =
|
||
detail.current?.max_so_far != null
|
||
? detail.current.max_so_far
|
||
: detail.current?.temp;
|
||
const currentTempText =
|
||
detail.current?.temp != null
|
||
? `${detail.current.temp}${detail.temp_symbol}`
|
||
: "--";
|
||
const daylightProgress = (() => {
|
||
const now = parseClockMinutes(detail.current?.obs_time);
|
||
const sunrise = parseClockMinutes(detail.forecast?.sunrise);
|
||
const sunset = parseClockMinutes(detail.forecast?.sunset);
|
||
if (now == null || sunrise == null || sunset == null || sunset <= sunrise) {
|
||
return null;
|
||
}
|
||
const percent = clamp(((now - sunrise) / (sunset - sunrise)) * 100, 0, 100);
|
||
const phase =
|
||
now < sunrise ? "夜间" : now > sunset ? "已日落" : "白昼进行中";
|
||
return {
|
||
phase,
|
||
percent,
|
||
};
|
||
})();
|
||
const displayedUpperAirSummary = showDeferredTodaySections
|
||
? upperAirCue?.summary || view.front.upperAirSummary
|
||
: "";
|
||
const displayedUpperAirMetrics = showDeferredTodaySections
|
||
? (view.front.upperAirMetrics || []).map((metric, index) =>
|
||
index === 0 &&
|
||
(metric.label === "Trade cue" || metric.label === "交易动作") &&
|
||
upperAirCue
|
||
? {
|
||
...metric,
|
||
note: upperAirCue.note,
|
||
tone: upperAirCue.tone,
|
||
value: upperAirCue.value,
|
||
}
|
||
: metric,
|
||
)
|
||
: [];
|
||
const localizedAiCommentaryLines = useMemo(() => {
|
||
if (!showDeferredTodaySections) return [] as string[];
|
||
const commentary = detail.dynamic_commentary || {};
|
||
const headline = String(
|
||
locale === "en-US" ? commentary.headline_en || "" : commentary.headline_zh || "",
|
||
).trim();
|
||
const bullets = (
|
||
locale === "en-US" ? commentary.bullets_en : commentary.bullets_zh
|
||
) as string[] | null | undefined;
|
||
const cleanedBullets = Array.isArray(bullets)
|
||
? bullets.map((item) => String(item || "").trim()).filter(Boolean)
|
||
: [];
|
||
return [headline, ...cleanedBullets].filter(Boolean).slice(0, 3);
|
||
}, [detail.dynamic_commentary, locale, showDeferredTodaySections]);
|
||
const todayTradeSummaryLines = useMemo(() => {
|
||
if (!showDeferredTodaySections) return [] as string[];
|
||
if (!isToday) return [] as string[];
|
||
if (localizedAiCommentaryLines.length > 0) {
|
||
return localizedAiCommentaryLines;
|
||
}
|
||
const lines: string[] = [];
|
||
if (paceView) {
|
||
const headline =
|
||
paceView.biasTone === "warm"
|
||
? locale === "en-US"
|
||
? `Pace is running hot by ${paceView.deltaText}; the day high still leans above the base curve.`
|
||
: `节奏偏热 ${paceView.deltaText},日高仍偏向落在基础曲线之上。`
|
||
: paceView.biasTone === "cold"
|
||
? locale === "en-US"
|
||
? `Pace is trailing by ${paceView.deltaText}; chasing higher buckets needs caution.`
|
||
: `节奏落后 ${paceView.deltaText},继续追更高温区间要更谨慎。`
|
||
: locale === "en-US"
|
||
? "Pace is still on curve; the next move depends on the peak-window push."
|
||
: "节奏目前贴着曲线走,下一步主要看峰值窗口还有没有上冲。";
|
||
lines.push(headline);
|
||
}
|
||
if (boundaryRiskView) {
|
||
lines.push(
|
||
locale === "en-US"
|
||
? `${boundaryRiskView.label}: ${boundaryRiskView.note}`
|
||
: `${boundaryRiskView.label}:${boundaryRiskView.note}`,
|
||
);
|
||
}
|
||
if (networkLeadView) {
|
||
lines.push(
|
||
locale === "en-US"
|
||
? `${networkLeadView.label}: ${networkLeadView.note}`
|
||
: `${networkLeadView.label}:${networkLeadView.note}`,
|
||
);
|
||
}
|
||
return lines.slice(0, 3);
|
||
}, [boundaryRiskView, isToday, locale, localizedAiCommentaryLines, networkLeadView, paceView, showDeferredTodaySections]);
|
||
const intradayMeteorology = detail.intraday_meteorology || {};
|
||
const meteorologySignals = Array.isArray(intradayMeteorology.signal_contributions)
|
||
? intradayMeteorology.signal_contributions
|
||
: [];
|
||
const invalidationRules = localizedList(
|
||
locale,
|
||
intradayMeteorology.invalidation_rules,
|
||
intradayMeteorology.invalidation_rules_en,
|
||
);
|
||
const confirmationRules = localizedList(
|
||
locale,
|
||
intradayMeteorology.confirmation_rules,
|
||
intradayMeteorology.confirmation_rules_en,
|
||
);
|
||
const meteorologyHeadline =
|
||
localizedText(
|
||
locale,
|
||
intradayMeteorology.headline,
|
||
intradayMeteorology.headline_en,
|
||
) ||
|
||
todayTradeSummaryLines[0] ||
|
||
(locale === "en-US"
|
||
? "Intraday meteorology layers are still syncing; use the next observation as the anchor."
|
||
: "关键日内气象层仍在同步,先以下一次观测作为判断锚点。");
|
||
const baseCaseBucket =
|
||
String(intradayMeteorology.base_case_bucket || "").trim() ||
|
||
formatBucketLabel(topProbabilityBucket);
|
||
const nextObservationTime =
|
||
String(intradayMeteorology.next_observation_time || "").trim() || "--";
|
||
const baseBucketNumber = parseLeadingNumber(baseCaseBucket);
|
||
const referenceObservedTemp =
|
||
topObservedTemp != null && Number.isFinite(Number(topObservedTemp))
|
||
? Number(topObservedTemp)
|
||
: detail.current?.temp != null
|
||
? Number(detail.current.temp)
|
||
: null;
|
||
const gapToBaseBucket =
|
||
baseBucketNumber != null && referenceObservedTemp != null
|
||
? Math.max(0, baseBucketNumber - referenceObservedTemp)
|
||
: null;
|
||
const pathStatus =
|
||
gapToBaseBucket == null
|
||
? locale === "en-US"
|
||
? "Awaiting anchor"
|
||
: "等待锚点"
|
||
: gapToBaseBucket <= 0.05
|
||
? locale === "en-US"
|
||
? "Base path touched"
|
||
: "基准路径已触达"
|
||
: gapToBaseBucket <= 1.0
|
||
? locale === "en-US"
|
||
? "Base path open"
|
||
: "基准路径开放"
|
||
: locale === "en-US"
|
||
? "Needs peak push"
|
||
: "需要峰值推动";
|
||
const peakWindowText =
|
||
String(intradayMeteorology.peak_window || "").trim() ||
|
||
paceView?.peakWindowText ||
|
||
"--";
|
||
const settlementSourceCode = String(
|
||
detail.current?.settlement_source || "",
|
||
).trim().toLowerCase();
|
||
const settlementStationCode = String(
|
||
detail.current?.station_code || detail.risk?.icao || "",
|
||
)
|
||
.trim()
|
||
.toUpperCase();
|
||
const settlementStationName =
|
||
String(detail.current?.station_name || detail.risk?.airport || "").trim() ||
|
||
settlementStationCode ||
|
||
(locale === "en-US" ? "Anchor station" : "锚点站");
|
||
const airportMetarAnchor =
|
||
settlementSourceCode === "metar" ||
|
||
settlementSourceCode === "wunderground" ||
|
||
Boolean(settlementStationCode && /^[A-Z]{4}$/.test(settlementStationCode));
|
||
const anchorSourceLabel = airportMetarAnchor
|
||
? settlementStationCode
|
||
? `${settlementStationCode} METAR`
|
||
: "METAR"
|
||
: detail.current?.settlement_source_label ||
|
||
detail.current?.settlement_source ||
|
||
(locale === "en-US" ? "Official observation" : "官方观测");
|
||
const anchorRuleText = airportMetarAnchor
|
||
? locale === "en-US"
|
||
? `Airport contract anchor: use the ${anchorSourceLabel} reports. Wunderground is only a history display page when present.`
|
||
: `机场合约锚点:以 ${anchorSourceLabel} 报文为准;若出现 Wunderground,它只是历史展示页面。`
|
||
: locale === "en-US"
|
||
? `Official anchor: use ${anchorSourceLabel} observations for this contract.`
|
||
: `官方锚点:该合约按 ${anchorSourceLabel} 观测口径判断。`;
|
||
const nextObservationLabel = airportMetarAnchor
|
||
? locale === "en-US"
|
||
? "Next METAR watch"
|
||
: "下一次 METAR 观察"
|
||
: locale === "en-US"
|
||
? "Next anchor watch"
|
||
: "下一次锚点观察";
|
||
const gapToBaseText =
|
||
gapToBaseBucket == null
|
||
? "--"
|
||
: `${gapToBaseBucket.toFixed(1)}${detail.temp_symbol || "°C"}`;
|
||
const syncStatusItems = [
|
||
{
|
||
key: "base",
|
||
state: "ready",
|
||
label:
|
||
locale === "en-US" ? "Base analysis ready" : "基础分析已加载",
|
||
note:
|
||
locale === "en-US"
|
||
? "Forecast curve, anchor state, and the core intraday view are available."
|
||
: "预测曲线、锚点状态和核心日内视图已经可用。",
|
||
},
|
||
{
|
||
key: "market",
|
||
state: "ready",
|
||
label:
|
||
locale === "en-US"
|
||
? "Probability layer ready"
|
||
: "概率层已加载",
|
||
note:
|
||
locale === "en-US"
|
||
? "Probability buckets are derived from the local model stack."
|
||
: "概率桶当前由本地模型栈推导。",
|
||
},
|
||
] as const;
|
||
|
||
return (
|
||
<div
|
||
className="modal-overlay"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="future-modal-title"
|
||
onClick={(event) => {
|
||
if (event.target === event.currentTarget) {
|
||
store.closeFutureModal();
|
||
}
|
||
}}
|
||
>
|
||
{isProLoading ? (
|
||
<div
|
||
className="modal-content large"
|
||
style={{ padding: "40px", textAlign: "center" }}
|
||
>
|
||
<div style={{ color: "var(--text-muted)" }}>
|
||
{t("dashboard.loading")}
|
||
</div>
|
||
</div>
|
||
) : !isPro ? (
|
||
<ProFeaturePaywall
|
||
feature={isToday ? "today" : "future"}
|
||
onClose={store.closeFutureModal}
|
||
/>
|
||
) : (
|
||
<div className="modal-content large future-modal">
|
||
<div className="modal-header">
|
||
<div className="modal-title-stack">
|
||
<div className="modal-overline">
|
||
<span>{locale === "en-US" ? "Analysis workspace" : "分析工作台"}</span>
|
||
<span className="modal-overline-sep">•</span>
|
||
<span>{detail.display_name.toUpperCase()}</span>
|
||
</div>
|
||
<h2
|
||
id="future-modal-title"
|
||
className="future-modal-title-with-actions"
|
||
>
|
||
<span>
|
||
{isToday
|
||
? t("future.todayTitle", {
|
||
city: detail.display_name.toUpperCase(),
|
||
})
|
||
: t("future.dateTitle", {
|
||
city: detail.display_name.toUpperCase(),
|
||
date: dateStr,
|
||
})}
|
||
</span>
|
||
<button
|
||
className={clsx(
|
||
"future-refresh-btn",
|
||
isAnyLayerSyncing && "spinning",
|
||
)}
|
||
disabled={!isPro || isProLoading}
|
||
onClick={() => {
|
||
if (isToday) {
|
||
void store.openTodayModal(true);
|
||
return;
|
||
}
|
||
store.openFutureModal(dateStr, true);
|
||
}}
|
||
title={
|
||
!isPro
|
||
? locale === "en-US"
|
||
? "Pro subscription required"
|
||
: "需要 Pro 订阅"
|
||
: locale === "en-US"
|
||
? "Refresh Data"
|
||
: "刷新数据"
|
||
}
|
||
>
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||
<path d="M3 3v5h5" />
|
||
</svg>
|
||
</button>
|
||
</h2>
|
||
<div className="modal-subtitle">
|
||
{isToday
|
||
? locale === "en-US"
|
||
? "Base signal first, then probability and model layers."
|
||
: "先看基础信号,再看概率层和模型层。"
|
||
: locale === "en-US"
|
||
? "Forward date view with phased model and structure sync."
|
||
: "未来日期视图,模型层与结构层分阶段补齐。"}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="modal-close"
|
||
aria-label={
|
||
isToday ? t("future.closeTodayAria") : t("future.closeDateAria")
|
||
}
|
||
onClick={store.closeFutureModal}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="modal-body future-modal-body">
|
||
{isToday && (
|
||
<section className="future-v2-meteorology-brief">
|
||
<div className="future-v2-meteorology-copy">
|
||
<div className="future-v2-anchor-row">
|
||
<div className="modal-section-kicker">
|
||
{locale === "en-US" ? "Professional meteorology read" : "专业气象判断"}
|
||
</div>
|
||
<span className="future-v2-anchor-source">{anchorSourceLabel}</span>
|
||
</div>
|
||
<h3>{meteorologyHeadline}</h3>
|
||
<p className="future-v2-anchor-rule">{anchorRuleText}</p>
|
||
<div className="future-v2-meteorology-meta">
|
||
<span>
|
||
{locale === "en-US" ? "Confidence" : "置信度"} ·{" "}
|
||
{formatConfidenceLabel(intradayMeteorology.confidence, locale)}
|
||
</span>
|
||
<span>
|
||
{locale === "en-US" ? "Path state" : "路径状态"} · {pathStatus}
|
||
</span>
|
||
<span>
|
||
{nextObservationLabel} · {nextObservationTime}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="future-v2-decision-rail">
|
||
<div className="future-v2-decision-anchor">
|
||
<span>{locale === "en-US" ? "Anchor" : "锚点"}</span>
|
||
<strong>{settlementStationName}</strong>
|
||
<small>{anchorSourceLabel}</small>
|
||
</div>
|
||
<div className="future-v2-decision-grid">
|
||
<div>
|
||
<span>{locale === "en-US" ? "Base" : "基准"}</span>
|
||
<strong>{baseCaseBucket || "--"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>{locale === "en-US" ? "Upside" : "上修"}</span>
|
||
<strong>{intradayMeteorology.upside_bucket || "--"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>{locale === "en-US" ? "Downside" : "下修"}</span>
|
||
<strong>{intradayMeteorology.downside_bucket || "--"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>{locale === "en-US" ? "Gap to base" : "距基准还差"}</span>
|
||
<strong>{gapToBaseText}</strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
<section
|
||
className={clsx(
|
||
"future-v2-sync-strip",
|
||
isToday && "future-v2-sync-strip-compact",
|
||
)}
|
||
aria-live="polite"
|
||
>
|
||
{syncStatusItems.map((item) => (
|
||
<div
|
||
key={item.key}
|
||
className="future-v2-sync-chip"
|
||
>
|
||
<span className="future-v2-sync-dot" aria-hidden="true" />
|
||
<div className="future-v2-sync-copy">
|
||
<strong>{item.label}</strong>
|
||
<span>{item.note}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</section>
|
||
{isNoaaSettlement && (
|
||
<div className="modal-callout modal-callout-info">
|
||
{locale === "en-US"
|
||
? `${detail.display_name} now settles against NOAA ${noaaStationCode} (${noaaStationName}). The market uses the highest rounded whole-degree Celsius reading in the Temp column after the day is finalized.`
|
||
: `${detail.display_name} 当前按 NOAA ${noaaStationCode}(${noaaStationName})结算。市场最终采用该日 Temp 列完成质控后的最高整度摄氏值,不按小数温度结算。`}
|
||
</div>
|
||
)}
|
||
{isToday ? (
|
||
<div className="future-v2-layout">
|
||
<aside className="future-v2-left">
|
||
<section className="future-v2-card future-v2-hero-card">
|
||
<div className="future-v2-card-head">
|
||
<h3 className="future-v2-hero-title">
|
||
{locale === "en-US"
|
||
? "Anchor Status"
|
||
: "锚点状态"}
|
||
</h3>
|
||
<div className="future-v2-card-kicker">
|
||
{locale === "en-US"
|
||
? "Settlement anchor and current clock"
|
||
: "结算锚点与当前时钟"}
|
||
</div>
|
||
</div>
|
||
<div className="future-v2-hero-main">
|
||
<div className="future-v2-hero-temp">
|
||
{currentTempText}
|
||
</div>
|
||
<div className="future-v2-hero-divider" />
|
||
<div className="future-v2-hero-weather">
|
||
<span className="future-v2-hero-icon">
|
||
<WeatherIcon
|
||
emoji={weatherSummary.weatherIcon}
|
||
size={42}
|
||
/>
|
||
</span>
|
||
<span>{weatherSummary.weatherText}</span>
|
||
</div>
|
||
</div>
|
||
<div className="future-v2-hero-obs">
|
||
@{detail.current?.obs_time || "--"}
|
||
</div>
|
||
{daylightProgress ? (
|
||
<div className="future-v2-daylight">
|
||
<div className="future-v2-daylight-head">
|
||
<span>
|
||
{locale === "en-US"
|
||
? "Solar Window"
|
||
: "昼夜进度"}
|
||
</span>
|
||
<strong>
|
||
{locale === "en-US"
|
||
? `${daylightProgress.phase} ${Math.round(daylightProgress.percent)}%`
|
||
: `${daylightProgress.phase} ${Math.round(daylightProgress.percent)}%`}
|
||
</strong>
|
||
</div>
|
||
<div
|
||
className="future-v2-daylight-bar"
|
||
style={
|
||
{
|
||
"--daylight-progress": `${daylightProgress.percent}%`,
|
||
} as CSSProperties & { "--daylight-progress": string }
|
||
}
|
||
/>
|
||
<div className="future-v2-daylight-times">
|
||
<span>{detail.forecast?.sunrise || "--"}</span>
|
||
<span>{detail.forecast?.sunset || "--"}</span>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
<div className="future-v2-mini-grid">
|
||
<div className="future-v2-mini-item">
|
||
<span>
|
||
{locale === "en-US" ? "High so far" : "日内已见高点"}
|
||
</span>
|
||
<strong>
|
||
{topObservedTemp ?? "--"}
|
||
{detail.temp_symbol}
|
||
</strong>
|
||
</div>
|
||
<div className="future-v2-mini-item">
|
||
<span>
|
||
{locale === "en-US" ? "Anchor clock" : "锚点时钟"}
|
||
</span>
|
||
<strong>{detail.current?.obs_time || "--"}</strong>
|
||
</div>
|
||
<div className="future-v2-mini-item">
|
||
<span>
|
||
{locale === "en-US" ? "Gap to base" : "距基准档"}
|
||
</span>
|
||
<strong>
|
||
{gapToBaseBucket != null
|
||
? `${gapToBaseBucket.toFixed(1)}${detail.temp_symbol}`
|
||
: "--"}
|
||
</strong>
|
||
</div>
|
||
<div className="future-v2-mini-item">
|
||
<span>
|
||
{locale === "en-US" ? "Path state" : "路径状态"}
|
||
</span>
|
||
<strong>{pathStatus}</strong>
|
||
</div>
|
||
<div className="future-v2-mini-item">
|
||
<span>
|
||
{locale === "en-US" ? "Sunrise" : "日出时间"}
|
||
</span>
|
||
<strong>{detail.forecast?.sunrise || "--"}</strong>
|
||
</div>
|
||
<div className="future-v2-mini-item">
|
||
<span>
|
||
{locale === "en-US" ? "Sunset" : "日落时间"}
|
||
</span>
|
||
<strong>
|
||
{detail.forecast?.sunset || "--"}
|
||
</strong>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{showDeferredTodaySections && paceView ? (
|
||
<section className="future-v2-card future-v2-pace-card future-v2-focus-card">
|
||
<div className="future-v2-card-head">
|
||
<h4 className="future-v2-card-title">
|
||
{locale === "en-US" ? "Current Pace" : "当前节奏"}
|
||
</h4>
|
||
<div className="future-v2-card-kicker">
|
||
{locale === "en-US"
|
||
? "Expected now vs airport anchor"
|
||
: "此刻应到 vs 机场锚点"}
|
||
</div>
|
||
</div>
|
||
<div className="future-v2-pace-head">
|
||
<span className="future-v2-pace-kicker">
|
||
{paceView.kicker}
|
||
</span>
|
||
<em
|
||
className={clsx(
|
||
"future-v2-signal-tag",
|
||
paceView.biasTone === "cold" && "cyan",
|
||
paceView.biasTone === "neutral" && "blue",
|
||
paceView.biasTone === "warm" && "amber",
|
||
)}
|
||
>
|
||
{paceView.badge}
|
||
</em>
|
||
</div>
|
||
<div
|
||
className={clsx(
|
||
"future-v2-pace-delta",
|
||
paceView.biasTone === "cold" && "cold",
|
||
paceView.biasTone === "neutral" && "neutral",
|
||
paceView.biasTone === "warm" && "warm",
|
||
)}
|
||
>
|
||
{paceView.deltaText}
|
||
</div>
|
||
<div className="future-v2-pace-summary">
|
||
{paceView.summary}
|
||
</div>
|
||
<div className="future-v2-pace-meter">
|
||
<span className="future-v2-pace-meter-midline" />
|
||
<span
|
||
className={clsx(
|
||
"future-v2-pace-meter-fill",
|
||
paceView.biasTone === "cold" && "cold",
|
||
paceView.biasTone === "neutral" && "neutral",
|
||
paceView.biasTone === "warm" && "warm",
|
||
)}
|
||
style={
|
||
{
|
||
"--pace-left": `${paceView.meterLeft}%`,
|
||
"--pace-width": `${paceView.meterWidth}%`,
|
||
} as CSSProperties & {
|
||
"--pace-left": string;
|
||
"--pace-width": string;
|
||
}
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
|
||
<div className="future-v2-mini-item">
|
||
<span>
|
||
{locale === "en-US" ? "Expected now" : "预期此刻"}
|
||
</span>
|
||
<strong>
|
||
{paceView.expectedNow.toFixed(1)}
|
||
{detail.temp_symbol}
|
||
</strong>
|
||
</div>
|
||
<div className="future-v2-mini-item">
|
||
<span>{paceView.observedLabel}</span>
|
||
<strong>
|
||
{paceView.observedNow.toFixed(1)}
|
||
{detail.temp_symbol}
|
||
</strong>
|
||
</div>
|
||
<div className="future-v2-mini-item">
|
||
<span>{paceView.paceAdjustedLabel}</span>
|
||
<strong>
|
||
{paceView.paceAdjustedHigh != null
|
||
? `${paceView.paceAdjustedHigh.toFixed(1)}${detail.temp_symbol}`
|
||
: "--"}
|
||
</strong>
|
||
</div>
|
||
<div className="future-v2-mini-item">
|
||
<span>
|
||
{locale === "en-US" ? "Peak window" : "峰值窗口"}
|
||
</span>
|
||
<strong>{paceView.peakWindowText}</strong>
|
||
</div>
|
||
</div>
|
||
<div className="future-v2-pace-signal-grid">
|
||
{[boundaryRiskView, peakWindowStateView, networkLeadView]
|
||
.filter((item) => item != null)
|
||
.map((item) => (
|
||
<div key={item.label} className="future-v2-pace-signal-card">
|
||
<div className="future-v2-signal-head">
|
||
<span>{item.label}</span>
|
||
<em
|
||
className={clsx(
|
||
"future-v2-signal-tag",
|
||
item.tone === "cyan" && "cyan",
|
||
item.tone === "blue" && "blue",
|
||
item.tone === "amber" && "amber",
|
||
)}
|
||
>
|
||
{item.status}
|
||
</em>
|
||
</div>
|
||
<strong>{item.value}</strong>
|
||
<div className="future-v2-pace-signal-note">
|
||
{item.note}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : isToday ? (
|
||
<section className="future-v2-card future-v2-support-card">
|
||
<div className="future-v2-card-head">
|
||
<h4 className="future-v2-card-title">
|
||
{locale === "en-US" ? "Current Pace" : "当前节奏"}
|
||
</h4>
|
||
<div className="future-v2-card-kicker">
|
||
{locale === "en-US"
|
||
? "Backfilling intraday pace context"
|
||
: "正在补齐日内节奏上下文"}
|
||
</div>
|
||
</div>
|
||
<div className="future-trend-summary future-trend-summary-muted">
|
||
{locale === "en-US"
|
||
? "Expected-now pace, boundary risk, and airport-vs-network cues are loading in the background."
|
||
: "预期此刻节奏、边界风险和机场对比站网信号正在后台补齐。"}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
</aside>
|
||
|
||
<main className="future-v2-right">
|
||
<section className="future-modal-section future-v2-main-chart">
|
||
<div className="modal-section-heading">
|
||
<div className="modal-section-kicker">
|
||
{locale === "en-US" ? "Primary view" : "主视图"}
|
||
</div>
|
||
<h3>
|
||
{locale === "en-US"
|
||
? "Today's temperature path (anchor obs + models)"
|
||
: "今日气温路径(锚点观测 + 模型)"}
|
||
</h3>
|
||
</div>
|
||
<DailyTemperatureChart dateStr={dateStr} />
|
||
<div className="future-v2-chart-thresholds">
|
||
<span>{locale === "en-US" ? "Base" : "基准"} · {baseCaseBucket || "--"}</span>
|
||
<span>{locale === "en-US" ? "Upside" : "上修"} · {intradayMeteorology.upside_bucket || "--"}</span>
|
||
<span>{locale === "en-US" ? "Invalidates at" : "失效观察"} · {nextObservationTime}</span>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="future-v2-meteorology-grid">
|
||
<section className="future-modal-section future-v2-evidence-panel">
|
||
<div className="modal-section-heading">
|
||
<div className="modal-section-kicker">
|
||
{locale === "en-US" ? "Evidence chain" : "气象证据链"}
|
||
</div>
|
||
<h3>{locale === "en-US" ? "Signal Contributions" : "信号贡献"}</h3>
|
||
</div>
|
||
<div className="future-v2-evidence-list">
|
||
{meteorologySignals.length > 0 ? (
|
||
meteorologySignals.map((signal, index) => (
|
||
<div
|
||
key={`${signal.label || "signal"}-${index}`}
|
||
className={clsx(
|
||
"future-v2-evidence-row",
|
||
signalTone(signal),
|
||
)}
|
||
>
|
||
<div className="future-v2-evidence-head">
|
||
<strong>
|
||
{localizedText(locale, signal.label, signal.label_en) || "--"}
|
||
</strong>
|
||
<span>
|
||
{formatSignalDirection(signal.direction, locale)} ·{" "}
|
||
{formatSignalStrength(signal.strength, locale)}
|
||
</span>
|
||
</div>
|
||
<p>
|
||
{localizedText(locale, signal.summary, signal.summary_en) || "--"}
|
||
</p>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="future-text-block">
|
||
{locale === "en-US"
|
||
? "Meteorology signals are still loading."
|
||
: "气象信号仍在加载。"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="future-modal-section future-v2-rule-panel">
|
||
<div className="modal-section-heading">
|
||
<div className="modal-section-kicker">
|
||
{locale === "en-US" ? "Failure modes" : "失效条件"}
|
||
</div>
|
||
<h3>{locale === "en-US" ? "What Downgrades the Read" : "什么会让判断降级"}</h3>
|
||
</div>
|
||
<ul className="future-v2-rule-list">
|
||
{(invalidationRules.length > 0
|
||
? invalidationRules
|
||
: [
|
||
locale === "en-US"
|
||
? "If observations stop tracking the expected curve, wait for the next refresh."
|
||
: "若实测不再贴近预期曲线,等待下一次刷新确认。",
|
||
]
|
||
).map((rule, index) => (
|
||
<li key={`${rule}-${index}`}>{rule}</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
|
||
<section className="future-modal-section future-v2-rule-panel">
|
||
<div className="modal-section-heading">
|
||
<div className="modal-section-kicker">
|
||
{locale === "en-US" ? "Confirmation" : "确认条件"}
|
||
</div>
|
||
<h3>{locale === "en-US" ? "What Confirms the Path" : "什么会确认主路径"}</h3>
|
||
</div>
|
||
<ul className="future-v2-rule-list">
|
||
{(confirmationRules.length > 0
|
||
? confirmationRules
|
||
: [
|
||
locale === "en-US"
|
||
? airportMetarAnchor
|
||
? "Keep watching the next anchor METAR report."
|
||
: "Keep watching the next official anchor observation."
|
||
: airportMetarAnchor
|
||
? "继续观察下一次锚点 METAR 报文。"
|
||
: "继续观察下一次官方锚点观测。",
|
||
]
|
||
).map((rule, index) => (
|
||
<li key={`${rule}-${index}`}>{rule}</li>
|
||
))}
|
||
</ul>
|
||
<div className="future-v2-model-note">{modelSummary}</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div className="future-modal-grid">
|
||
<section className="future-modal-section">
|
||
<div className="modal-section-heading">
|
||
<div className="modal-section-kicker">
|
||
{locale === "en-US" ? "Auxiliary probability" : "辅助概率"}
|
||
</div>
|
||
<h3>
|
||
{locale === "en-US" ? "Model & Market Reference" : "模型与市场参考"}
|
||
</h3>
|
||
</div>
|
||
<div className="future-text-block" style={{ marginBottom: "12px" }}>
|
||
{probabilitySummary}
|
||
</div>
|
||
<div style={{ position: "relative", minHeight: "120px" }}>
|
||
<ProbabilityDistribution
|
||
detail={detail}
|
||
targetDate={dateStr}
|
||
marketScan={detail.market_scan}
|
||
hideTitle
|
||
/>
|
||
</div>
|
||
</section>
|
||
<section className="future-modal-section">
|
||
<div className="modal-section-heading">
|
||
<div className="modal-section-kicker">
|
||
{locale === "en-US" ? "Model layer" : "模型层"}
|
||
</div>
|
||
<h3>
|
||
{locale === "en-US" ? "Model Range & Spread" : "模型区间与分歧"}
|
||
</h3>
|
||
</div>
|
||
<div className="future-text-block" style={{ marginBottom: "12px" }}>
|
||
{modelSummary}
|
||
</div>
|
||
<ModelForecast
|
||
detail={detail}
|
||
targetDate={dateStr}
|
||
hideTitle
|
||
/>
|
||
</section>
|
||
</div>
|
||
|
||
</main>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="history-stats">
|
||
<div className="h-stat-card">
|
||
<span className="label">{t("future.targetForecast")}</span>
|
||
<span className="val">
|
||
{view.forecastEntry?.max_temp ?? "--"}
|
||
{detail.temp_symbol}
|
||
</span>
|
||
</div>
|
||
<div className="h-stat-card">
|
||
<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">{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">{t("future.score")}</span>
|
||
<span className="val">
|
||
{view.front.score > 0 ? "+" : ""}
|
||
{view.front.score}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<section className="future-modal-section">
|
||
<h3>{t("future.targetTempTrend")}</h3>
|
||
<DailyTemperatureChart dateStr={dateStr} />
|
||
</section>
|
||
|
||
<div className="future-modal-grid">
|
||
<section className="future-modal-section">
|
||
<h3>{t("future.probability")}</h3>
|
||
<ProbabilityDistribution
|
||
detail={detail}
|
||
targetDate={dateStr}
|
||
hideTitle
|
||
/>
|
||
</section>
|
||
<section className="future-modal-section">
|
||
<h3>{t("future.models")}</h3>
|
||
<ModelForecast
|
||
detail={detail}
|
||
targetDate={dateStr}
|
||
hideTitle
|
||
/>
|
||
</section>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|