diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index bdcdff9f..f3fabccd 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -27,10 +27,10 @@ import { getFutureModalView, getModelView, getProbabilityView, - getTodayPaceView, getTemperatureChartData, getWeatherSummary, } from "@/lib/dashboard-utils"; +import { getTodayPaceView } from "@/lib/pace-utils"; import { dashboardClient } from "@/lib/dashboard-client"; import { normalizeObservationSourceCode, diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx index 62f3461e..9ff0f342 100644 --- a/frontend/components/dashboard/OpportunityTable.tsx +++ b/frontend/components/dashboard/OpportunityTable.tsx @@ -11,8 +11,8 @@ import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; import { getModelView, getProbabilityView, - getTodayPaceView, } from "@/lib/dashboard-utils"; +import { getTodayPaceView } from "@/lib/pace-utils"; import { formatTemperatureValue, normalizeTemperatureLabel, diff --git a/frontend/components/dashboard/scan-terminal/AiForecastKPIBar.tsx b/frontend/components/dashboard/scan-terminal/AiForecastKPIBar.tsx index 1ff8ca4a..39117d30 100644 --- a/frontend/components/dashboard/scan-terminal/AiForecastKPIBar.tsx +++ b/frontend/components/dashboard/scan-terminal/AiForecastKPIBar.tsx @@ -1,5 +1,5 @@ import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types"; -import { getTodayPaceView } from "@/lib/dashboard-utils"; +import { getTodayPaceView } from "@/lib/pace-utils"; import { formatTemperatureValue } from "@/lib/temperature-utils"; import { getPeakWindowLabel } from "@/components/dashboard/scan-terminal/decision-utils"; diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx index cc60865a..b4c331b9 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx @@ -21,7 +21,8 @@ import { useCityMarketScan, } from "@/components/dashboard/scan-terminal/use-ai-city-card-data"; import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types"; -import { getModelView, getTodayPaceView } from "@/lib/dashboard-utils"; +import { getModelView } from "@/lib/dashboard-utils"; +import { getTodayPaceView } from "@/lib/pace-utils"; import { formatTemperatureValue } from "@/lib/temperature-utils"; function toFiniteDecisionNumber(value: unknown) { diff --git a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts index 7252f972..2ed3362f 100644 --- a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts +++ b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts @@ -1,5 +1,5 @@ import type { MarketScan, MarketTopBucket } from "@/lib/dashboard-types"; -import { getTodayPaceView } from "@/lib/dashboard-utils"; +import { getTodayPaceView } from "@/lib/pace-utils"; import { formatTemperatureValue, normalizeTemperatureLabel, diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 4afaec67..44c805c6 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -11,7 +11,13 @@ import { normalizeTemperatureLabel, normalizeTemperatureSymbol, } from "@/lib/temperature-utils"; +import { + hmToMinutes, + interpolateSeriesAtMinutes, + normalizeHm, +} from "@/lib/time-utils"; +export { getTodayPaceView } from "@/lib/pace-utils"; export { formatTemperatureValue, normalizeTemperatureLabel, @@ -240,34 +246,6 @@ 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 findNearestTimeIndex( times: string[], targetTime?: string | null, @@ -478,196 +456,6 @@ function buildCurrentObservationFallback( ]; } -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[] = []; diff --git a/frontend/lib/pace-utils.ts b/frontend/lib/pace-utils.ts new file mode 100644 index 00000000..35bc685a --- /dev/null +++ b/frontend/lib/pace-utils.ts @@ -0,0 +1,156 @@ +import type { CityDetail } from "@/lib/dashboard-types"; +import type { Locale } from "@/lib/i18n"; +import { + hmToMinutes, + interpolateSeriesAtMinutes, + normalizeHm, +} from "@/lib/time-utils"; + +function isEnglish(locale: Locale) { + return locale === "en-US"; +} + +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, + }; +} diff --git a/frontend/lib/time-utils.ts b/frontend/lib/time-utils.ts new file mode 100644 index 00000000..0966084d --- /dev/null +++ b/frontend/lib/time-utils.ts @@ -0,0 +1,72 @@ +export 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")}`; +} + +export 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; +} + +export 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; +}