diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 61db9d7a..ed38fb2a 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -2772,6 +2772,36 @@ padding: 14px; } +.root :global(.future-v2-card-head) { + display: grid; + gap: 6px; +} + +.root :global(.future-v2-card-kicker) { + color: var(--text-muted); + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.root :global(.future-v2-focus-card) { + background: + linear-gradient( + 180deg, + rgba(34, 211, 238, 0.08) 0%, + rgba(255, 255, 255, 0.02) 100% + ); +} + +.root :global(.future-v2-support-card) { + background: + linear-gradient( + 180deg, + rgba(148, 163, 184, 0.08) 0%, + rgba(255, 255, 255, 0.02) 100% + ); +} + .root :global(.future-v2-hero-card) { background: linear-gradient( 180deg, @@ -3075,6 +3105,27 @@ line-height: 1.55; } +.root :global(.future-v2-pace-signal-grid) { + margin-top: 12px; + display: grid; + gap: 10px; +} + +.root :global(.future-v2-pace-signal-card) { + border-radius: 10px; + border: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.025); + padding: 10px; + display: grid; + gap: 8px; +} + +.root :global(.future-v2-pace-signal-note) { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} + .root :global(.future-v2-pace-meter) { position: relative; height: 10px; @@ -3228,6 +3279,20 @@ position: relative; } +.root :global(.future-v2-market-card) { + background: + radial-gradient( + circle at top right, + rgba(34, 197, 94, 0.08) 0%, + rgba(15, 23, 42, 0) 40% + ), + linear-gradient( + 180deg, + rgba(7, 16, 26, 0.96) 0%, + rgba(255, 255, 255, 0.02) 100% + ); +} + .root :global(.market-layer-loading-overlay) { position: absolute; top: 0; diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index e12b1103..a1022699 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -26,6 +26,8 @@ import { } from "@/components/dashboard/PanelSections"; import { getFutureModalView, + getModelView, + getProbabilityView, getTodayPaceView, parseAiAnalysis, getTemperatureChartData, @@ -182,6 +184,46 @@ function parseVisibilityText( 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); } @@ -707,6 +749,158 @@ export function FutureForecastModal() { () => (isToday ? getTodayPaceView(detail, locale) : null), [detail, isToday, locale], ); + 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 { + max, + min, + spread, + }; + }, [modelView]); + const boundaryRiskView = useMemo(() => { + if (!isToday || !paceView) return null; + const selectedBucket = marketScan?.temperature_bucket || 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, marketScan?.temperature_bucket, paceView]); + const peakWindowStateView = useMemo(() => { + 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]); + const networkLeadView = useMemo(() => { + 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]); const isNoaaSettlement = detail.current?.settlement_source === "noaa" || detail.current?.settlement_source_label === "NOAA"; @@ -766,6 +960,48 @@ export function FutureForecastModal() { marketScan.confidence ? ` / ${marketScan.confidence}` : "" }` : "--"; + const marketExecutionSummary = (() => { + if (!marketScan?.available) { + return locale === "en-US" + ? "Market scan is unavailable right now; keep the trade read anchored to station pace and bracket risk." + : "当前暂时拿不到市场扫描结果,先以站点节奏和边界风险为主。"; + } + if (marketScan?.signal_label?.toUpperCase() === "BUY YES") { + return locale === "en-US" + ? `Model side is hotter than the tape by ${marketEdge}. If pace stays constructive, the Yes side still has room.` + : `模型侧当前比盘口更热 ${marketEdge},如果盘中节奏不掉,Yes 侧仍有空间。`; + } + if (marketScan?.signal_label?.toUpperCase() === "BUY NO") { + return locale === "en-US" + ? `Model side is cooler than the tape by ${marketEdge}. If pace keeps lagging, the No side stays cleaner.` + : `模型侧当前比盘口更冷 ${marketEdge},如果节奏继续落后,No 侧会更干净。`; + } + return locale === "en-US" + ? "Model and market are broadly balanced; let pace and boundary risk break the tie." + : "模型和市场当前大体均衡,接下来主要看节奏和边界风险来决定站边。"; + })(); + 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." + : "当前拿不到可用的模型分歧。"; + } + 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 topBucketProbability = normalizeMarketValue(topBucket?.probability); @@ -1003,6 +1239,33 @@ export function FutureForecastModal() { ...getCurrentMetricDescriptor("visibility", visibilityText, visibilityValue), }, ]; + const tradeMetricVisuals = currentMetricVisuals.filter((metric) => + ["dewpoint", "wind", "humidity"].includes(metric.key), + ); + const tradeMetricSummary = (() => { + const dewSpread = + detail.current?.temp != null && fallbackDewpoint != null + ? Number(detail.current.temp) - Number(fallbackDewpoint) + : null; + if (dewSpread != null && dewSpread <= 2) { + return locale === "en-US" + ? "Near-saturation air is still in play. If cloud builds into the peak window, upside can get capped quickly." + : "露点差已经很窄,空气接近饱和。如果云层在峰值窗口内长起来,上冲会更容易被压住。"; + } + if (windValue != null && windValue >= 18) { + return locale === "en-US" + ? "Surface wind is still strong. Mixing support is better, but it can also make the tape noisier intraday." + : "近地面风速仍偏强,混合作用会更充分,但盘中波动也会更大。"; + } + if (humidityValue != null && humidityValue <= 40) { + return locale === "en-US" + ? "Air mass is still relatively dry. Unless cloud/rain arrives, the surface layer is not the main cap right now." + : "空气仍偏干,除非后续云雨快速起来,否则近地面层目前不是主要压温来源。"; + } + return locale === "en-US" + ? "Surface readings are broadly neutral. Keep the main decision anchored to pace, boundary risk, and the peak window." + : "近地面指标整体偏中性,主判断仍然要看当前节奏、边界风险和峰值窗口。"; + })(); const displayedUpperAirSummary = marketAwareUpperAirCue?.summary || view.front.upperAirSummary; const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map( @@ -1018,6 +1281,40 @@ export function FutureForecastModal() { } : metric, ); + const todayTradeSummaryLines = useMemo(() => { + if (!isToday) return [] as string[]; + const lines: string[] = []; + if (paceView) { + const headline = + paceView.biasTone === "warm" + ? locale === "en-US" + ? `Intraday pace is running hot by ${paceView.deltaText}; the day high still has room to lean above the base curve.` + : `日内节奏当前偏热 ${paceView.deltaText},日高仍有机会落在基础曲线之上。` + : paceView.biasTone === "cold" + ? locale === "en-US" + ? `Intraday pace is trailing by ${paceView.deltaText}; higher buckets need more caution.` + : `日内节奏当前落后 ${paceView.deltaText},继续追更高温区间要更谨慎。` + : locale === "en-US" + ? "Intraday pace is still tracking the curve; the next move depends on the peak-window push." + : "日内节奏当前基本贴着曲线运行,下一步主要看峰值窗口内还有没有上冲。"; + lines.push(headline); + } + if (boundaryRiskView) { + lines.push( + locale === "en-US" + ? `${boundaryRiskView.label}: ${boundaryRiskView.status}. ${boundaryRiskView.note}` + : `${boundaryRiskView.label}:${boundaryRiskView.status}。${boundaryRiskView.note}`, + ); + } + if (networkLeadView) { + lines.push( + locale === "en-US" + ? `${networkLeadView.label}: ${networkLeadView.status}. ${networkLeadView.note}` + : `${networkLeadView.label}:${networkLeadView.status}。${networkLeadView.note}`, + ); + } + return lines; + }, [boundaryRiskView, isToday, locale, networkLeadView, paceView]); return (