diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 0c4b755a..61db9d7a 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -3019,6 +3019,110 @@ background: linear-gradient(90deg, #b45309 0%, #f59e0b 100%); } +.root :global(.future-v2-pace-card) { + background: + radial-gradient( + circle at top right, + rgba(34, 211, 238, 0.12) 0%, + rgba(15, 23, 42, 0) 42% + ), + linear-gradient( + 180deg, + rgba(8, 15, 28, 0.96) 0%, + rgba(255, 255, 255, 0.02) 100% + ); +} + +.root :global(.future-v2-pace-head) { + margin-top: 12px; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; +} + +.root :global(.future-v2-pace-kicker) { + color: var(--text-muted); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.root :global(.future-v2-pace-delta) { + margin-top: 10px; + font-size: 34px; + font-weight: 800; + letter-spacing: -0.04em; + line-height: 1.05; +} + +.root :global(.future-v2-pace-delta.warm) { + color: #fbbf24; +} + +.root :global(.future-v2-pace-delta.cold) { + color: #67e8f9; +} + +.root :global(.future-v2-pace-delta.neutral) { + color: #e2e8f0; +} + +.root :global(.future-v2-pace-summary) { + margin-top: 8px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; +} + +.root :global(.future-v2-pace-meter) { + position: relative; + height: 10px; + margin-top: 12px; + border-radius: 999px; + overflow: hidden; + background: + linear-gradient( + 90deg, + rgba(34, 211, 238, 0.12) 0%, + rgba(255, 255, 255, 0.06) 46%, + rgba(255, 255, 255, 0.06) 54%, + rgba(251, 191, 36, 0.12) 100% + ); +} + +.root :global(.future-v2-pace-meter-midline) { + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + background: rgba(255, 255, 255, 0.2); + transform: translateX(-50%); + z-index: 1; +} + +.root :global(.future-v2-pace-meter-fill) { + position: absolute; + top: 0; + left: var(--pace-left, 46%); + width: var(--pace-width, 8%); + bottom: 0; + border-radius: inherit; +} + +.root :global(.future-v2-pace-meter-fill.warm) { + background: linear-gradient(90deg, #b45309 0%, #fbbf24 100%); +} + +.root :global(.future-v2-pace-meter-fill.cold) { + background: linear-gradient(90deg, #0f766e 0%, #67e8f9 100%); +} + +.root :global(.future-v2-pace-meter-fill.neutral) { + background: linear-gradient(90deg, #475569 0%, #cbd5e1 100%); +} + .root :global(.intraday-scene-shell) { margin-top: 12px; margin-bottom: 10px; diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index ea35b306..1d529cd8 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -27,6 +27,7 @@ import { import { getFutureModalView, getAirportNarrative, + getTodayPaceView, parseAiAnalysis, getTemperatureChartData, getWeatherSummary, @@ -703,6 +704,10 @@ export function FutureForecastModal() { "--score-position": scorePosition, } as CSSProperties & { "--score-position": string }; const weatherSummary = getWeatherSummary(detail, locale); + const paceView = useMemo( + () => (isToday ? getTodayPaceView(detail, locale) : null), + [detail, isToday, locale], + ); const isNoaaSettlement = detail.current?.settlement_source === "noaa" || detail.current?.settlement_source_label === "NOAA"; @@ -1281,6 +1286,94 @@ export function FutureForecastModal() { + {paceView ? ( +
+

+ {locale === "en-US" ? "Current Pace" : "当前节奏 Pace"} +

+
+ + {paceView.kicker} + + + {paceView.badge} + +
+
+ {paceView.deltaText} +
+
+ {paceView.summary} +
+
+ + +
+
+
+ + {locale === "en-US" ? "Expected now" : "预期此刻"} + + + {paceView.expectedNow.toFixed(1)} + {detail.temp_symbol} + +
+
+ {paceView.observedLabel} + + {paceView.observedNow.toFixed(1)} + {detail.temp_symbol} + +
+
+ {paceView.paceAdjustedLabel} + + {paceView.paceAdjustedHigh != null + ? `${paceView.paceAdjustedHigh.toFixed(1)}${detail.temp_symbol}` + : "--"} + +
+
+ + {locale === "en-US" ? "Peak window" : "峰值窗口"} + + {paceView.peakWindowText} +
+
+
+ ) : null} +

{locale === "en-US" ? "Current Metrics" : "当前指标"} diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index e09e2dfa..85993d55 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -194,6 +194,224 @@ export function getWeatherSummary(detail: CityDetail, locale: Locale = "zh-CN") return { weatherIcon, weatherText }; } +function normalizeHm(value?: string | null) { + const match = String(value || "").match(/(\d{1,2}):(\d{2})/); + if (!match) return null; + const hour = Number.parseInt(match[1], 10); + const minute = Number.parseInt(match[2], 10); + if ( + !Number.isFinite(hour) || + !Number.isFinite(minute) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 + ) { + return null; + } + return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; +} + +function hmToMinutes(value?: string | null) { + const normalized = normalizeHm(value); + if (!normalized) return null; + const [hourText, minuteText] = normalized.split(":"); + const hour = Number.parseInt(hourText || "", 10); + const minute = Number.parseInt(minuteText || "", 10); + if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null; + return hour * 60 + minute; +} + +function interpolateSeriesAtMinutes( + times: string[], + values: Array, + currentMinutes: number, +) { + const points = times + .map((time, index) => { + const minute = hmToMinutes(time); + const value = values[index]; + return minute != null && value != null && Number.isFinite(Number(value)) + ? { minute, value: Number(value) } + : null; + }) + .filter((point): point is { minute: number; value: number } => point != null); + + if (!points.length) return null; + + const exact = points.find((point) => point.minute === currentMinutes); + if (exact) return exact.value; + + let left: { minute: number; value: number } | null = null; + let right: { minute: number; value: number } | null = null; + + for (const point of points) { + if (point.minute < currentMinutes) { + left = point; + continue; + } + if (point.minute > currentMinutes) { + right = point; + break; + } + } + + if (left && right) { + const span = right.minute - left.minute; + if (span <= 0) return left.value; + const ratio = (currentMinutes - left.minute) / span; + return Number((left.value + (right.value - left.value) * ratio).toFixed(1)); + } + if (left) return left.value; + if (right) return right.value; + return null; +} + +export function getTodayPaceView( + detail: CityDetail, + locale: Locale = "zh-CN", +) { + const hourly = detail.hourly || {}; + const times = hourly.times || []; + const temps = hourly.temps || []; + if (!times.length || !temps.length) return null; + + const currentMinutes = + hmToMinutes(detail.local_time) ?? + hmToMinutes(detail.airport_primary?.obs_time) ?? + hmToMinutes(detail.airport_current?.obs_time) ?? + hmToMinutes(detail.current?.obs_time); + if (currentMinutes == null) return null; + + const omHigh = Number(detail.forecast?.today_high); + const debHigh = Number(detail.deb?.prediction); + const useDebOffset = Number.isFinite(omHigh) && Number.isFinite(debHigh); + const offset = useDebOffset ? debHigh - omHigh : 0; + const expectedSeries = temps.map((temp) => + temp != null && Number.isFinite(Number(temp)) + ? Number((Number(temp) + offset).toFixed(1)) + : null, + ); + const expectedNow = interpolateSeriesAtMinutes(times, expectedSeries, currentMinutes); + if (expectedNow == null) return null; + + const observedNowCandidate = [ + detail.airport_primary?.temp, + detail.airport_current?.temp, + detail.current?.temp, + ] + .map((value) => Number(value)) + .find((value) => Number.isFinite(value)); + if (observedNowCandidate == null) return null; + const observedNow = Number(observedNowCandidate); + + const delta = Number((observedNow - expectedNow).toFixed(1)); + const biasMagnitude = Math.abs(delta); + const biasTone = + delta >= 0.6 ? "warm" : delta <= -0.6 ? "cold" : "neutral"; + const badge = + biasTone === "warm" + ? isEnglish(locale) + ? "Running hot" + : "跑得偏热" + : biasTone === "cold" + ? isEnglish(locale) + ? "Running cool" + : "跑得偏冷" + : isEnglish(locale) + ? "On track" + : "基本跟踪"; + const kicker = isEnglish(locale) + ? `As of ${normalizeHm(detail.local_time) || detail.local_time || "--:--"}` + : `截至 ${normalizeHm(detail.local_time) || detail.local_time || "--:--"}`; + const deltaText = + delta === 0 + ? isEnglish(locale) + ? "0.0°C vs expected" + : "0.0°C 相对预期" + : `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`; + + const topObservedCandidate = [ + detail.airport_primary?.max_so_far, + detail.airport_current?.max_so_far, + detail.current?.max_so_far, + observedNow, + ] + .map((value) => Number(value)) + .find((value) => Number.isFinite(value)); + const topObserved = topObservedCandidate != null ? Number(topObservedCandidate) : null; + const projectedBase = Number.isFinite(debHigh) + ? debHigh + : Number.isFinite(omHigh) + ? omHigh + : null; + const paceAdjustedHigh = + projectedBase != null + ? Number( + Math.max(projectedBase + delta, topObserved ?? projectedBase).toFixed(1), + ) + : topObserved; + const paceAdjustedLabel = isEnglish(locale) + ? "Pace-adjusted high" + : "节奏修正高点"; + const peakWindowText = + Number.isFinite(Number(detail.peak?.first_h)) && + Number.isFinite(Number(detail.peak?.last_h)) + ? `${String(Number(detail.peak?.first_h)).padStart(2, "0")}:00-${String( + Number(detail.peak?.last_h) + 1, + ).padStart(2, "0")}:00` + : "--"; + const observedLabel = + detail.airport_primary?.temp != null || detail.airport_current?.temp != null + ? isEnglish(locale) + ? "Airport obs" + : "机场实测" + : isEnglish(locale) + ? "Current obs" + : "当前实测"; + + const paceSummary = + biasTone === "warm" + ? isEnglish(locale) + ? `The airport anchor is ${biasMagnitude.toFixed(1)}°C above the intraday curve. If that bias survives into the peak window, the day high is more likely to lean hotter than the current DEB path.` + : `机场主站当前比盘中曲线高 ${biasMagnitude.toFixed(1)}°C。若这段偏热节奏延续进峰值窗口,日高更容易落在当前 DEB 路径之上。` + : biasTone === "cold" + ? isEnglish(locale) + ? `The airport anchor is ${biasMagnitude.toFixed(1)}°C below the intraday curve. If that drag survives into the peak window, chasing higher buckets becomes harder.` + : `机场主站当前比盘中曲线低 ${biasMagnitude.toFixed(1)}°C。若这段偏冷节奏延续进峰值窗口,继续追更高温区间会更吃力。` + : isEnglish(locale) + ? "The airport anchor is still tracking the intraday curve. Let later pace and peak-window structure decide." + : "机场主站当前仍基本贴着盘中曲线运行,后续主要看峰值窗口内的节奏有没有进一步偏离。"; + + const clamped = Math.min(Math.max(delta, -4), 4); + const meterLeft = + biasTone === "neutral" + ? 46 + : clamped >= 0 + ? 50 + : 50 - (Math.abs(clamped) / 4) * 50; + const meterWidth = + biasTone === "neutral" ? 8 : Math.max((Math.abs(clamped) / 4) * 50, 8); + + return { + badge, + biasTone, + delta, + deltaText, + expectedNow, + kicker, + meterLeft, + meterWidth, + observedLabel, + observedNow, + paceAdjustedHigh, + paceAdjustedLabel, + peakWindowText, + summary: paceSummary, + topObserved, + }; +} + export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") { const current = detail.current || {}; const parts: string[] = [];