Files

159 lines
5.5 KiB
TypeScript
Raw Permalink Normal View History

import type { CityDetail } from "@/lib/dashboard-types";
import { getDisplayAirportPrimary } from "@/lib/airport-observation-display";
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 displayAirportPrimary = getDisplayAirportPrimary(detail);
const currentMinutes =
hmToMinutes(detail.local_time) ??
hmToMinutes(displayAirportPrimary?.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 = [
displayAirportPrimary?.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 = [
displayAirportPrimary?.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 =
displayAirportPrimary?.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,
};
}