"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 { dashboardClient } from "@/lib/dashboard-client"; import type { IntradayMeteorologySignal, MarketScan } 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 ; if (emoji === "⛅" || emoji === "🌤️") return ; if (emoji === "☁️") return ; if (emoji === "🌧️" || emoji === "🌦️") return ; if (emoji === "⛈️") return ; if (emoji === "❄️" || emoji === "🌨️") return ; if (emoji === "🌫️") return ; if (emoji === "💨") return ; return ; } 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, forceToday = false, }: { dateStr: string; forceToday?: boolean; }) { const store = useDashboardStore(); const { locale, t } = useI18n(); const detail = store.selectedDetail; const view = detail ? getFutureModalView(detail, dateStr, locale) : null; const isToday = forceToday || (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 ( <>
{isToday && (
{todayChartData?.legendText || t("future.chartLegendEmpty")}
)} ); } 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); const [freshMarketScan, setFreshMarketScan] = useState(null); if (!detail || !dateStr) return null; useEffect(() => { setShowDeferredTodaySections(false); if (typeof window === "undefined") { setShowDeferredTodaySections(true); return; } let cancelled = false; let timeoutId: ReturnType | 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 = store.forecastModalMode === "today" || (store.forecastModalMode == null && dateStr === detail.local_date); const detailDepth = detail.detail_depth || "full"; const isFullDetailReady = detailDepth === "full"; const isStructureSyncing = store.loadingState.futureDeep || !isFullDetailReady; const isAnyLayerSyncing = isStructureSyncing; const isTodayBlockingRefresh = isToday && isStructureSyncing; const activeMarketScan = freshMarketScan || detail.market_scan || null; useEffect(() => { setFreshMarketScan(null); if (!isToday || !isFullDetailReady || !isPro) return; const cityName = String(detail.name || detail.display_name || "").trim(); if (!cityName || !dateStr) return; let cancelled = false; let intervalId: ReturnType | null = null; const refreshMarketScan = () => { dashboardClient .getCityMarketScan(cityName, { force: false, marketSlug: detail.market_scan?.primary_market?.slug || null, targetDate: dateStr, }) .then((payload) => { if (cancelled) return; setFreshMarketScan(payload.market_scan || null); }) .catch(() => { if (!cancelled) { setFreshMarketScan(null); } }); }; refreshMarketScan(); intervalId = setInterval(() => { if (typeof document !== "undefined" && document.visibilityState === "hidden") { return; } refreshMarketScan(); }, 3000); return () => { cancelled = true; if (intervalId != null) { clearInterval(intervalId); } }; }, [ dateStr, detail.display_name, detail.local_date, detail.market_scan?.primary_market?.slug, detail.name, detail.updated_at, isFullDetailReady, isPro, isToday, ]); 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 probabilityEngineKey = String(probabilityView?.engine || "") .trim() .toLowerCase(); const probabilityCalibrationMode = String( probabilityView?.calibrationMode || "", ) .trim() .toLowerCase(); const hasLgbmProbability = useMemo( () => Object.keys(modelView?.models || {}).some((name) => String(name || "").toLowerCase().replace(/[\s_/-]/g, "").includes("lgbm"), ), [modelView], ); const hasEmosProbability = probabilityEngineKey === "emos" || probabilityCalibrationMode.includes("emos"); const probabilityEngineLabel = hasLgbmProbability ? locale === "en-US" ? "LGBM" : "LGBM" : hasEmosProbability ? "EMOS" : locale === "en-US" ? "Calibrated model" : "校准模型"; const probabilityTitle = hasLgbmProbability ? locale === "en-US" ? "LGBM-Calibrated Probability" : "LGBM 校准概率" : hasEmosProbability ? locale === "en-US" ? "EMOS-Calibrated Probability" : "EMOS 校准概率" : locale === "en-US" ? "Calibrated Model Probability" : "校准模型概率"; 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 leaderSyncStatus = String(leadSignal?.leader_sync_status || "").trim().toLowerCase(); const leaderSyncDelta = Number(leadSignal?.leader_time_delta_vs_anchor_minutes); const syncNote = leaderSyncStatus === "near_realtime" || leaderSyncStatus === "lagged" ? Number.isFinite(leaderSyncDelta) ? locale === "en-US" ? ` Timing offset versus the airport anchor is about ${Math.round(leaderSyncDelta)} minutes.` : ` 与机场锚点存在约 ${Math.round(leaderSyncDelta)} 分钟时间差。` : locale === "en-US" ? " Nearby observations are not fully synchronized." : " 周边观测并非完全同步。" : leaderSyncStatus === "unknown" ? locale === "en-US" ? " Nearby station timing is not fully verified." : " 周边站观测时间尚未完全校验。" : ""; 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}` : ""}.${syncNote}` : `机场主站当前比周边官方站网低 ${absDelta.toFixed(1)}${detail.temp_symbol}${leaderLabel ? `,领先点位是 ${leaderLabel}` : ""}。${syncNote}` : delta >= 0.4 ? locale === "en-US" ? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} hotter than the nearby official network.${syncNote}` : `机场主站当前比周边官方站网高 ${absDelta.toFixed(1)}${detail.temp_symbol}。${syncNote}` : 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); if (!isToday) { return locale === "en-US" ? `Target-day model probability reference puts the leading bucket at ${bucketLabel} (${bucketProb}). EMOS is reserved for intraday analysis after live anchor observations arrive.` : `目标日模型概率参考显示领先温度桶为 ${bucketLabel}(${bucketProb})。EMOS 仅用于有实时锚点观测后的日内分析。`; } if (hasLgbmProbability) { return locale === "en-US" ? `LGBM-calibrated read puts the leading bucket at ${bucketLabel} (${bucketProb}). Treat this as the base case, not the final settlement.` : `LGBM 校准后领先温度桶为 ${bucketLabel}(${bucketProb})。可作为基准情形,但不要直接等同于最终结算。`; } if (hasEmosProbability) { return locale === "en-US" ? `EMOS-calibrated probability puts the leading bucket at ${bucketLabel} (${bucketProb}). It is the primary calibrated probability layer, not the final settlement.` : `EMOS 校准概率显示领先温度桶为 ${bucketLabel}(${bucketProb})。这是当前主概率层,但不要直接等同于最终结算。`; } return locale === "en-US" ? `Calibrated model probability puts the leading bucket at ${bucketLabel} (${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: isAnyLayerSyncing ? "syncing" : "ready", label: isAnyLayerSyncing ? locale === "en-US" ? "Refreshing base analysis" : "正在刷新基础分析" : locale === "en-US" ? "Base analysis ready" : "基础分析已加载", note: isAnyLayerSyncing ? locale === "en-US" ? "Latest anchor readings and forecast curve are being rebuilt." : "正在重建最新锚点读数和预测曲线。" : locale === "en-US" ? "Forecast curve, anchor state, and the core intraday view are available." : "预测曲线、锚点状态和核心日内视图已经可用。", }, { key: "market", state: isAnyLayerSyncing ? "syncing" : "ready", label: isAnyLayerSyncing ? locale === "en-US" ? "Refreshing probability layer" : "正在刷新概率层" : locale === "en-US" ? "Probability layer ready" : "概率层已加载", note: isAnyLayerSyncing ? locale === "en-US" ? "Model spread and calibrated buckets are updating." : "模型分歧和校准概率桶正在更新。" : locale === "en-US" ? `Probability buckets are derived from the ${probabilityEngineLabel} layer.` : `概率桶当前由 ${probabilityEngineLabel} 层推导。`, }, ] as const; return (
{ if (event.target === event.currentTarget) { store.closeFutureModal(); } }} > {isProLoading ? (
{t("dashboard.loading")}
) : !isPro ? ( ) : (
{locale === "en-US" ? "Analysis workspace" : "分析工作台"} {detail.display_name.toUpperCase()}

{isToday ? t("future.todayTitle", { city: detail.display_name.toUpperCase(), }) : t("future.dateTitle", { city: detail.display_name.toUpperCase(), date: dateStr, })}

{isToday ? locale === "en-US" ? "Base signal first, then probability and model layers." : "先看基础信号,再看概率层和模型层。" : locale === "en-US" ? "Forward date view with phased model and structure sync." : "未来日期视图,模型层与结构层分阶段补齐。"}
{isTodayBlockingRefresh && (
)} {isToday && (
{locale === "en-US" ? "Professional meteorology read" : "专业气象判断"}
{anchorSourceLabel}

{meteorologyHeadline}

{anchorRuleText}

{locale === "en-US" ? "Confidence" : "置信度"} ·{" "} {formatConfidenceLabel(intradayMeteorology.confidence, locale)} {locale === "en-US" ? "Path state" : "路径状态"} · {pathStatus} {nextObservationLabel} · {nextObservationTime}
{locale === "en-US" ? "Anchor" : "锚点"} {settlementStationName} {anchorSourceLabel}
{locale === "en-US" ? "Base" : "基准"} {baseCaseBucket || "--"}
{locale === "en-US" ? "Upside" : "上修"} {intradayMeteorology.upside_bucket || "--"}
{locale === "en-US" ? "Downside" : "下修"} {intradayMeteorology.downside_bucket || "--"}
{locale === "en-US" ? "Gap to base" : "距基准还差"} {gapToBaseText}
)}
{syncStatusItems.map((item) => (
))}
{isNoaaSettlement && (
{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 列完成质控后的最高整度摄氏值,不按小数温度结算。`}
)} {isToday ? (
{locale === "en-US" ? "Primary view" : "主视图"}

{locale === "en-US" ? "Today's temperature path (anchor obs + models)" : "今日气温路径(锚点观测 + 模型)"}

{locale === "en-US" ? "Base" : "基准"} · {baseCaseBucket || "--"} {locale === "en-US" ? "Upside" : "上修"} · {intradayMeteorology.upside_bucket || "--"} {locale === "en-US" ? "Invalidates at" : "失效观察"} · {nextObservationTime}
{locale === "en-US" ? "Evidence chain" : "气象证据链"}

{locale === "en-US" ? "Signal Contributions" : "信号贡献"}

{meteorologySignals.length > 0 ? ( meteorologySignals.map((signal, index) => (
{localizedText(locale, signal.label, signal.label_en) || "--"} {formatSignalDirection(signal.direction, locale)} ·{" "} {formatSignalStrength(signal.strength, locale)}

{localizedText(locale, signal.summary, signal.summary_en) || "--"}

)) ) : (
{locale === "en-US" ? "Meteorology signals are still loading." : "气象信号仍在加载。"}
)}
{locale === "en-US" ? "Failure modes" : "失效条件"}

{locale === "en-US" ? "What Downgrades the Read" : "什么会让判断降级"}

    {(invalidationRules.length > 0 ? invalidationRules : [ locale === "en-US" ? "If observations stop tracking the expected curve, wait for the next refresh." : "若实测不再贴近预期曲线,等待下一次刷新确认。", ] ).map((rule, index) => (
  • {rule}
  • ))}
{locale === "en-US" ? "Confirmation" : "确认条件"}

{locale === "en-US" ? "What Confirms the Path" : "什么会确认主路径"}

    {(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) => (
  • {rule}
  • ))}
{modelSummary}
{locale === "en-US" ? "Probability read" : "概率判断"}

{probabilityTitle}

{probabilitySummary}
{locale === "en-US" ? "Model layer" : "模型层"}

{locale === "en-US" ? "Model Range & Spread" : "模型区间与分歧"}

{modelSummary}
) : ( <>
{t("future.targetForecast")} {view.forecastEntry?.max_temp ?? "--"} {detail.temp_symbol}
{t("future.deb")} {view.deb ?? "--"} {detail.temp_symbol}
{t("future.mu")} {view.mu != null ? `${view.mu.toFixed(1)}${detail.temp_symbol}` : "--"}
{t("future.score")} {view.front.score > 0 ? "+" : ""} {view.front.score}

{t("future.targetTempTrend")}

{t("future.probability")}

{t("future.models")}

)}
)}
); }