"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, useMemo } from "react"; import { useChart } from "@/hooks/useChart"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { IntradaySignalScene } from "@/components/dashboard/IntradaySignalScene"; import { ModelForecast, ProbabilityDistribution, } from "@/components/dashboard/PanelSections"; import { getFutureModalView, getAirportNarrative, parseAiAnalysis, getTemperatureChartData, getWeatherSummary, } from "@/lib/dashboard-utils"; 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 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 formatMarketPriceCents(value?: number | null) { const normalized = normalizeMarketValue(value); if (normalized == null) return "--"; const cents = normalized * 100; const rounded = Math.round(cents * 10) / 10; return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}¢`; } function formatSignedPercent(value?: number | null) { const numeric = Number(value); if (!Number.isFinite(numeric)) return "--"; const sign = numeric > 0 ? "+" : ""; return `${sign}${numeric.toFixed(1)}%`; } function formatSpreadPercent(low?: number | null, high?: number | null) { const a = normalizeMarketValue(low); const b = normalizeMarketValue(high); if (a == null || b == null) return "--"; const spreadCents = Math.abs((b - a) * 100); const rounded = Math.round(spreadCents * 10) / 10; return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}¢`; } function resolveCounterPrice( directValue?: number | null, mirrorValue?: number | null, ) { const direct = normalizeMarketValue(directValue); if (direct != null) return direct; const mirror = normalizeMarketValue(mirrorValue); if (mirror == null) return null; return Math.max(0, Math.min(1, 1 - mirror)); } 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 parseMetarSignedInt(token: string) { if (!token) return null; const normalized = token.toUpperCase(); if (!/^[M]?\d{2}$/.test(normalized)) return null; const value = Number(normalized.replace("M", "")); if (!Number.isFinite(value)) return null; return normalized.startsWith("M") ? -value : value; } function parseMetarTempDew(rawMetar?: string | null) { const text = String(rawMetar || "").toUpperCase(); if (!text) return { tempC: null as number | null, dewC: null as number | null }; const match = text.match(/\s(M?\d{2})\/(M?\d{2})(?:\s|$)/); if (!match) return { tempC: null as number | null, dewC: null as number | null }; return { tempC: parseMetarSignedInt(match[1]), dewC: parseMetarSignedInt(match[2]), }; } function estimateHumidityFromTempDew( tempC?: number | null, dewC?: number | null, ) { const t = Number(tempC); const d = Number(dewC); if (!Number.isFinite(t) || !Number.isFinite(d)) return null; const es = Math.exp((17.625 * t) / (243.04 + t)); const ed = Math.exp((17.625 * d) / (243.04 + d)); const rh = (ed / es) * 100; if (!Number.isFinite(rh)) return null; return Math.max(0, Math.min(100, rh)); } function parseVisibilityText( rawMetar?: string | null, visibilityMi?: number | null, ) { const direct = Number(visibilityMi); if (Number.isFinite(direct)) { return `${direct} mi`; } const text = String(rawMetar || "").toUpperCase(); if (!text) return "--"; if (text.includes("CAVOK")) return ">=6 mi"; const sm = text.match(/\s(\d{1,2}(?:\/\d)?)SM(?:\s|$)/); if (sm) { return `${sm[1]} mi`; } return "--"; } 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 parseVisibilityMiles(value?: string | null) { const text = String(value || "").trim(); if (!text || text === "--") return null; const match = text.match(/(\d+(?:\.\d+)?)/); if (!match) return null; const numeric = Number(match[1]); return Number.isFinite(numeric) ? numeric : null; } 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 getCurrentMetricDescriptor( kind: "humidity" | "dewpoint" | "wind" | "visibility", value: string, numeric: number | null, aux?: number | null, ) { if (kind === "humidity") { const percent = numeric != null ? clamp(numeric, 0, 100) : null; const hint = percent == null ? "--" : percent >= 75 ? "偏湿" : percent >= 45 ? "适中" : "偏干"; const tone = percent == null ? "neutral" : percent >= 75 ? "cyan" : percent >= 45 ? "blue" : "amber"; return { fill: percent, hint, tone, value }; } if (kind === "dewpoint") { const spread = aux != null && numeric != null ? aux - numeric : null; const closeness = spread != null ? clamp(100 - spread * 12, 0, 100) : null; const hint = spread == null ? "--" : spread <= 2 ? "近饱和" : spread <= 6 ? "偏湿" : "偏干"; const tone = spread == null ? "neutral" : spread <= 2 ? "cyan" : spread <= 6 ? "blue" : "amber"; return { fill: closeness, hint, tone, value }; } if (kind === "wind") { const percent = numeric != null ? clamp((numeric / 25) * 100, 0, 100) : null; const hint = numeric == null ? "--" : numeric >= 18 ? "偏强" : numeric >= 8 ? "中等" : "较弱"; const tone = numeric == null ? "neutral" : numeric >= 18 ? "amber" : numeric >= 8 ? "blue" : "cyan"; return { fill: percent, hint, tone, value }; } const percent = numeric != null ? clamp((numeric / 10) * 100, 0, 100) : null; const hint = numeric == null ? "--" : numeric >= 6 ? "通透" : numeric >= 3 ? "一般" : "受限"; const tone = numeric == null ? "neutral" : numeric >= 6 ? "cyan" : numeric >= 3 ? "blue" : "amber"; return { fill: percent, hint, tone, value }; } 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 (numeric == null) return null; if (label.includes("降水") || label.includes("precip")) { return { mode: "fill" as const, percent: clamp(numeric, 0, 100), tone: "cold" as const, }; } 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.mgmHourlyPoints, fill: false, label: locale === "en-US" ? "MGM Forecast" : "MGM 预测", pointHoverRadius: 6, pointRadius: 3, spanGaps: true, tension: 0.3, }); } else { datasets.push({ backgroundColor: "rgba(52, 211, 153, 0.05)", borderColor: "rgba(52, 211, 153, 0.6)", borderWidth: 1.5, data: todayChartData.datasets.debPast, fill: true, label: locale === "en-US" ? "DEB Forecast" : "DEB 预测", pointHoverRadius: 3, pointRadius: 0, tension: 0.3, }); datasets.push({ borderColor: "rgba(52, 211, 153, 0.35)", borderDash: [5, 3], borderWidth: 1.5, data: todayChartData.datasets.debFuture, fill: false, label: locale === "en-US" ? "DEB Forecast" : "DEB 预测", pointRadius: 0, tension: 0.3, }); } datasets.push({ backgroundColor: "#22d3ee", borderColor: "#22d3ee", borderWidth: 0, data: todayChartData.datasets.metarPoints, fill: false, label: todayChartData.observationLabel || (locale === "en-US" ? "Observation" : "观测实况"), order: 0, pointHoverRadius: 7, pointRadius: 5, }); if ( todayChartData.datasets.airportMetarPoints?.some((value) => value != null) ) { datasets.push({ backgroundColor: "#60a5fa", borderColor: "#60a5fa", borderWidth: 1, data: todayChartData.datasets.airportMetarPoints, fill: false, label: locale === "en-US" ? "Airport METAR" : "机场 METAR", order: 0, pointHoverRadius: 6, pointRadius: 4, showLine: false, }); } if (todayChartData.datasets.mgmPoints.some((value) => value != null)) { datasets.push({ backgroundColor: "#facc15", borderColor: "#facc15", borderWidth: 0, data: todayChartData.datasets.mgmPoints, fill: false, label: locale === "en-US" ? "MGM Observation" : "MGM 实测", order: -1, pointHoverRadius: 9, pointRadius: 7, showLine: false, }); } if ( !todayChartData.datasets.hasMgmHourly && Math.abs(todayChartData.datasets.offset) > 0.3 ) { datasets.push({ borderColor: "rgba(99, 102, 241, 0.2)", borderDash: [2, 4], borderWidth: 1, data: todayChartData.datasets.temps, fill: false, label: locale === "en-US" ? "OM Raw" : "OM 原始", pointRadius: 0, tension: 0.3, }); } if ((todayChartData.tafMarkers || []).length > 0) { datasets.push({ backgroundColor: "#f59e0b", borderColor: "#f59e0b", borderWidth: 0, data: todayChartData.datasets.tafCurrentMarkerPoints, fill: false, label: locale === "en-US" ? "Current TAF" : "当前 TAF", order: -3, 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.tafPeakWindowMarkerPoints, fill: false, label: locale === "en-US" ? "Peak-window TAF" : "峰值窗口 TAF", order: -2, pointHoverRadius: 7, pointRadius: 4, showLine: false, }); datasets.push({ backgroundColor: "#f59e0b", borderColor: "#f59e0b", borderWidth: 0, data: todayChartData.datasets.tafMarkerPoints, fill: false, label: locale === "en-US" ? "TAF Timing" : "TAF 时段", order: -4, pointHoverRadius: 0, pointRadius: 0, showLine: false, }); } return { data: { datasets, labels: todayChartData.times, }, options: { interaction: { intersect: false, mode: "index" }, maintainAspectRatio: false, plugins: { legend: { labels: { color: "#94a3b8", filter: (legendItem, chartData) => { const text = String(legendItem.text || ""); if (!text) return false; if (text === "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: { label: (ctx) => { const label = String(ctx.dataset.label || ""); if ( label === "TAF Timing" || label === "TAF 时段" || label === "Current TAF" || label === "当前 TAF" || label === "Peak-window TAF" || label === "峰值窗口 TAF" ) { const marker = (todayChartData.tafMarkers || []).find( (item) => item.index === ctx.dataIndex, ); if (!marker) return label; 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}: ${String(marker.summary || "").replace( marker.markerType, marker.displayType || marker.markerType, )}`; } const value = ctx.parsed.y; if (value == null) return label; return `${label}: ${value.toFixed(1)}${detail.temp_symbol || "°C"}`; }, }, }, }, responsive: true, scales: { x: { grid: { color: "rgba(255,255,255,0.04)" }, ticks: { callback: (_value, index) => typeof index === "number" && index % 3 === 0 ? todayChartData.times[index] : "", color: "#64748b", font: { family: "Inter", size: 10 }, maxRotation: 0, }, }, y: { grid: { color: "rgba(255,255,255,0.04)" }, max: todayChartData.max, min: todayChartData.min, ticks: { callback: (value) => `${value}${detail.temp_symbol || "°C"}`, color: "#64748b", font: { family: "Inter", size: 10 }, }, }, }, }, type: "line", } satisfies ChartConfiguration<"line">; } const labels = view.slice.map((point) => point.label); const unit = detail.temp_symbol || "°C"; return { data: { datasets: [ { backgroundColor: "rgba(34, 211, 238, 0.08)", borderColor: "#22d3ee", data: view.slice.map((point) => point.temp), fill: false, label: 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 marketScan = store.selectedMarketScan; const dateStr = store.futureModalDate; const isPro = store.proAccess.subscriptionActive; const isProLoading = store.proAccess.loading; if (!detail || !dateStr) return null; const isToday = dateStr === detail.local_date; 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 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 marketMidpoint = formatMarketPercent( marketScan?.market_price ?? marketScan?.yes_token?.implied_probability, ); const modelProbability = formatMarketPercent(marketScan?.model_probability); const marketYesBuy = formatMarketPriceCents(marketScan?.yes_buy); const marketYesSell = formatMarketPriceCents(marketScan?.yes_sell); const marketNoBuy = formatMarketPriceCents( resolveCounterPrice(marketScan?.no_buy, marketScan?.yes_buy), ); const marketNoSell = formatMarketPriceCents( resolveCounterPrice(marketScan?.no_sell, marketScan?.yes_sell), ); const marketEdge = formatSignedPercent(marketScan?.edge_percent); const marketSpread = formatSpreadPercent( marketScan?.yes_buy, marketScan?.yes_sell, ); const topBucket = Array.isArray(marketScan?.top_buckets) ? [...marketScan.top_buckets] .map((item) => ({ ...item, probability: normalizeMarketValue(item?.probability), })) .filter( ( item, ): item is { label?: string | null; bucket?: string | null; range?: string | null; value?: number | null; temp?: number | null; probability: number; } => item.probability != null, ) .sort((a, b) => b.probability - a.probability)[0] : null; const settlementBucketLabel = formatBucketLabel( marketScan?.temperature_bucket, ); const hottestBucketLabel = formatBucketLabel(topBucket); const hottestBucketProb = formatMarketPercent(topBucket?.probability); const marketSignal = marketScan?.signal_label ? `${marketScan.signal_label}${ marketScan.confidence ? ` / ${marketScan.confidence}` : "" }` : "--"; const upperAirSignal = detail.vertical_profile_signal || {}; const tafSignal = detail.taf?.signal || {}; const topBucketProbability = normalizeMarketValue(topBucket?.probability); const numericEdge = Number(marketScan?.edge_percent); const hottestMatchesSettlement = hottestBucketLabel !== "--" && settlementBucketLabel !== "--" && hottestBucketLabel === settlementBucketLabel; const marketAwareUpperAirCue = useMemo(() => { if (!isToday || (!upperAirSignal.source && !tafSignal.available)) return null; const crowded = hottestMatchesSettlement && (topBucketProbability || 0) >= 0.3; 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 signalLabel = String(marketScan?.signal_label || "").toUpperCase(); const edgeAbs = Number.isFinite(numericEdge) ? Math.abs(numericEdge) : 0; const strongEdge = edgeAbs >= 8; 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 (strongEdge && signalLabel === "BUY YES") { score += 1; reasons.push( locale === "en-US" ? `market edge still leans hotter (${formatSignedPercent(numericEdge)})` : `市场 edge 仍偏向更热一侧(${formatSignedPercent(numericEdge)})`, ); } else if (strongEdge && signalLabel === "BUY NO") { score -= 1; reasons.push( locale === "en-US" ? `market edge still leans cooler (${formatSignedPercent(numericEdge)})` : `市场 edge 仍偏向更冷一侧(${formatSignedPercent(numericEdge)})`, ); } if (crowded && score > 0) { score -= 0.5; reasons.push( locale === "en-US" ? "the target bucket is already getting crowded" : "目标区间已经开始变拥挤", ); } if (score >= 1.5) { return { summary: locale === "en-US" ? "The combined upper-air, TAF, and market 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, TAF, and market 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, TAF, and market read is mixed. Let surface structure and price action 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, marketScan?.signal_label, hottestMatchesSettlement, isToday, locale, numericEdge, topBucketProbability, upperAirSignal.heating_setup, upperAirSignal.source, ]); const metarParsed = parseMetarTempDew(detail.current?.raw_metar); const fallbackDewpoint = detail.current?.dewpoint ?? metarParsed.dewC ?? (Array.isArray(detail.hourly_next_48h?.dew_point) ? detail.hourly_next_48h?.dew_point?.[0] : null); const fallbackHumidity = detail.current?.humidity ?? estimateHumidityFromTempDew( detail.current?.temp ?? metarParsed.tempC, fallbackDewpoint, ); 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 humidityText = fallbackHumidity != null ? `${Math.round(fallbackHumidity)}%` : "--"; const dewpointText = fallbackDewpoint != null ? `${fallbackDewpoint}${detail.temp_symbol}` : "--"; const windText = detail.current?.wind_speed_kt != null ? `${detail.current.wind_speed_kt} kt` : "--"; const visibilityText = parseVisibilityText( detail.current?.raw_metar, detail.current?.visibility_mi, ); const humidityValue = fallbackHumidity != null ? Math.round(fallbackHumidity) : null; const windValue = detail.current?.wind_speed_kt != null ? Number(detail.current.wind_speed_kt) : null; const visibilityValue = parseVisibilityMiles(visibilityText); 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 currentMetricVisuals = [ { key: "humidity", label: locale === "en-US" ? "Humidity" : "湿度", ...getCurrentMetricDescriptor("humidity", humidityText, humidityValue), }, { key: "dewpoint", label: locale === "en-US" ? "Dew Point" : "露点", ...getCurrentMetricDescriptor( "dewpoint", dewpointText, fallbackDewpoint != null ? Number(fallbackDewpoint) : null, detail.current?.temp != null ? Number(detail.current.temp) : null, ), }, { key: "wind", label: locale === "en-US" ? "Wind" : "风速", ...getCurrentMetricDescriptor("wind", windText, windValue), }, { key: "visibility", label: locale === "en-US" ? "Visibility" : "能见度", ...getCurrentMetricDescriptor("visibility", visibilityText, visibilityValue), }, ]; const ai = getAirportNarrative(detail, locale); const risk = detail.risk || {}; const settlementSourceCode = String( detail.current?.settlement_source || "", ).toLowerCase(); const isOfficialSettlementSource = settlementSourceCode === "hko" || settlementSourceCode === "cwa" || settlementSourceCode === "noaa" || settlementSourceCode === "wunderground"; const settlementProfileLabel = isOfficialSettlementSource ? locale === "en-US" ? "Settlement source" : "结算源" : t("section.airport"); const settlementProfileValue = settlementSourceCode === "hko" ? locale === "en-US" ? "Hong Kong Observatory (HKO)" : "香港天文台 (HKO)" : settlementSourceCode === "noaa" ? locale === "en-US" ? `NOAA ${noaaStationCode} (${noaaStationName})` : `NOAA ${noaaStationCode}(${noaaStationName})` : settlementSourceCode === "cwa" ? locale === "en-US" ? "Central Weather Administration (CWA)" : "交通部中央气象署 (CWA)" : settlementSourceCode === "wunderground" ? locale === "en-US" ? `${detail.current?.settlement_source_label || risk.airport || "Wunderground"}` : `${detail.current?.settlement_source_label || risk.airport || "Wunderground"}` : risk.airport ? `${risk.airport}${risk.icao ? ` (${risk.icao})` : ""}` : "--"; const airportCurrentText = detail.airport_current?.temp != null ? `${detail.airport_current.temp}${detail.temp_symbol}${ detail.airport_current?.obs_time ? ` @${detail.airport_current.obs_time}` : "" }` : "--"; const airportMaxText = detail.airport_current?.max_so_far != null ? `${detail.airport_current.max_so_far}${detail.temp_symbol}${ detail.airport_current?.max_temp_time ? ` @${detail.airport_current.max_temp_time}` : "" }` : "--"; const displayedUpperAirSummary = marketAwareUpperAirCue?.summary || view.front.upperAirSummary; const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map( (metric, index) => index === 0 && (metric.label === "Trade cue" || metric.label === "交易动作") && marketAwareUpperAirCue ? { ...metric, note: marketAwareUpperAirCue.note, tone: marketAwareUpperAirCue.tone, value: marketAwareUpperAirCue.value, } : metric, ); return (
{ if (event.target === event.currentTarget) { store.closeFutureModal(); } }} > {isProLoading ? (
{t("dashboard.loading")}
) : !isPro ? ( ) : (

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

{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" ? "Today's temperature forecast (obs + market)" : "今日气温预测(观测 + 市场)"}

{t("future.probability")}

{/* Loading Overlay */} {store.loadingState.marketScan && (
{locale === "en-US" ? "Crunching Polymarket Edges..." : "正在同步市场挂单..."}
)}

{t("future.models")}

{t("future.structureToday")}

{t("future.judgement")}: {view.front.label} {t("future.confidence")}:{" "} {t(`confidence.${view.front.confidence}`)} {t("future.maxPrecip")}:{" "} {Math.round(view.front.precipMax)}%
{view.front.summary ? (
{Array.isArray(view.front.summaryLines) && view.front.summaryLines.length > 0 ? view.front.summaryLines.map((line, index) => (
{line}
)) : view.front.summary}
) : null}
{locale === "en-US" ? "Surface Structure" : "近地面信号"}
{view.front.metrics.slice(0, 6).map((metric) => (
{metric.label}
{metric.value}
{getTrendMetricVisual(metric) ? (
{getTrendMetricVisual(metric)?.mode === "center" ? ( ) : null}
) : null}
{metric.note}
))}
<>
{locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"}
{displayedUpperAirSummary ? (
{displayedUpperAirSummary}
) : (
{locale === "en-US" ? "Upper-air structure is temporarily unavailable for this city. For now, lean on surface structure and TAF timing." : "该城市当前暂无可用的高空结构数据,先以近地面结构和 TAF 时段作为主判断。"}
)} {displayedUpperAirMetrics.length > 0 ? (
{displayedUpperAirMetrics.map((metric) => (
{metric.label}
{metric.value}
{getTrendMetricVisual(metric) ? (
{getTrendMetricVisual(metric)?.mode === "center" ? ( ) : null}
) : null}
{metric.note}
))}
) : (
{locale === "en-US" ? "Upper-air source" : "高空数据源"}
{locale === "en-US" ? "Not available" : "暂不可用"}
{locale === "en-US" ? "No upper-air diagnostic feed is attached to this city right now." : "当前该城市未接入可用的高空诊断源,所以这里先保留说明卡片。"}
)}
) : ( <>
{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")}

)}
)}
); }