2026-04-28 11:45:34 +08:00
import type { CityDetail } from "@/lib/dashboard-types" ;
2026-05-17 14:33:23 +08:00
import { getDisplayAirportPrimary } from "@/lib/airport-observation-display" ;
2026-04-28 11:45:34 +08:00
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 ;
2026-05-17 14:33:23 +08:00
const displayAirportPrimary = getDisplayAirportPrimary ( detail );
2026-04-28 11:45:34 +08:00
const currentMinutes =
hmToMinutes ( detail . local_time ) ??
2026-05-17 14:33:23 +08:00
hmToMinutes ( displayAirportPrimary ? . obs_time ) ??
2026-04-28 11:45:34 +08:00
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 = [
2026-05-17 14:33:23 +08:00
displayAirportPrimary ? . temp ,
2026-04-28 11:45:34 +08:00
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 = [
2026-05-17 14:33:23 +08:00
displayAirportPrimary ? . max_so_far ,
2026-04-28 11:45:34 +08:00
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 =
2026-05-17 14:33:23 +08:00
displayAirportPrimary ? . temp != null || detail . airport_current ? . temp != null
2026-04-28 11:45:34 +08:00
? 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 ,
};
}