2026-03-10 04:45:40 +08:00
import { Locale } from "@/lib/i18n" ;
import {
AiAnalysisStructured ,
CityDetail ,
HistoryPoint ,
NearbyStation ,
} from "@/lib/dashboard-types" ;
const METAR_WX_MAP : Record <
string ,
{ en : string ; icon : string ; zh : string }
> = {
2026-03-24 04:21:23 +08:00
VCSH : { en : "Showers nearby" , icon : "🌦️" , zh : "附近有阵雨" },
SHRA : { en : "Rain showers" , icon : "🌦️" , zh : "阵雨" },
"-SHRA" : { en : "Light rain showers" , icon : "🌦️" , zh : "小阵雨" },
"+SHRA" : { en : "Heavy rain showers" , icon : "⛈️" , zh : "强阵雨" },
VCRA : { en : "Rain nearby" , icon : "🌧️" , zh : "附近有降雨" },
TSRA : { en : "Thunderstorms with rain" , icon : "⛈️" , zh : "雷雨" },
"-TSRA" : { en : "Light thunderstorms with rain" , icon : "⛈️" , zh : "小雷雨" },
"+TSRA" : { en : "Heavy thunderstorms with rain" , icon : "⛈️" , zh : "强雷雨" },
2026-03-10 04:45:40 +08:00
RA : { en : "Rain" , icon : "🌧️" , zh : "降雨" },
"-RA" : { en : "Light rain" , icon : "🌦️" , zh : "小雨" },
"+RA" : { en : "Heavy rain" , icon : "⛈️" , zh : "强降雨" },
SN : { en : "Snow" , icon : "❄️" , zh : "降雪" },
"-SN" : { en : "Light snow" , icon : "🌨️" , zh : "小雪" },
"+SN" : { en : "Heavy snow" , icon : "🌨️" , zh : "大雪" },
DZ : { en : "Drizzle" , icon : "🌦️" , zh : "毛毛雨" },
FG : { en : "Fog" , icon : "🌫️" , zh : "雾" },
2026-03-24 04:21:23 +08:00
VCFG : { en : "Fog nearby" , icon : "🌫️" , zh : "附近有雾" },
MIFG : { en : "Shallow fog" , icon : "🌫️" , zh : "浅雾" },
2026-03-10 04:45:40 +08:00
BR : { en : "Mist" , icon : "🌫️" , zh : "薄雾" },
HZ : { en : "Haze" , icon : "🌫️" , zh : "霾" },
TS : { en : "Thunderstorm" , icon : "⛈️" , zh : "雷暴" },
VCTS : { en : "Nearby thunderstorm" , icon : "⛈️" , zh : "附近雷暴" },
SQ : { en : "Squall" , icon : "💨" , zh : "飑线" },
GS : { en : "Hail" , icon : "🌨️" , zh : "冰雹" },
2026-03-09 10:36:03 +08:00
};
2026-03-10 04:45:40 +08:00
function isEnglish ( locale : Locale ) {
return locale === "en-US" ;
}
2026-03-24 02:07:15 +08:00
function containsCjk ( text : string ) {
return /[\u3400-\u9fff]/ . test ( text );
}
2026-04-08 11:41:34 +08:00
function getLocalizedDynamicCommentary (
detail : CityDetail ,
locale : Locale ,
) : { headline : string ; bullets : string []; source : string } {
const commentary = detail . dynamic_commentary || {};
const preferEnglish = isEnglish ( locale );
const rawHeadline = preferEnglish
? String ( commentary . headline_en || "" ). trim ()
: String ( commentary . headline_zh || "" ). trim ();
const rawBullets = preferEnglish
? commentary.bullets_en
: commentary.bullets_zh ;
const bullets = Array . isArray ( rawBullets )
? rawBullets . map (( item ) => String ( item || "" ). trim ()). filter ( Boolean )
: [];
const fallbackHeadline = String ( commentary . summary || "" ). trim ();
const fallbackBullets = Array . isArray ( commentary . notes )
? commentary . notes . map (( item ) => String ( item || "" ). trim ()). filter ( Boolean )
: [];
return {
headline : rawHeadline || fallbackHeadline ,
bullets : bullets.length > 0 ? bullets : fallbackBullets ,
source : String ( commentary . source || "" ). trim (),
};
}
2026-03-30 19:03:13 +08:00
function isTurkishMgmCity ( detail : CityDetail ) {
const city = String ( detail . name || detail . display_name || "" )
. trim ()
. toLowerCase ();
return city === "ankara" || city === "istanbul" ;
}
2026-03-17 23:15:13 +08:00
function getObservationSourceCode ( detail : CityDetail ) : string {
2026-03-17 23:30:25 +08:00
const source = String ( detail . current ? . settlement_source || "" )
2026-03-17 23:15:13 +08:00
. trim ()
. toLowerCase ();
2026-03-17 23:30:25 +08:00
if ( source ) return source ;
const city = String ( detail . name || detail . display_name || "" )
. trim ()
. toLowerCase ();
2026-03-27 20:58:38 +08:00
if (
city === "hong kong" ||
city === "shek kong" ||
city === "lau fau shan"
) {
return "hko" ;
}
2026-03-23 14:39:41 +08:00
if ( city === "taipei" ) return "noaa" ;
2026-03-17 23:30:25 +08:00
return "metar" ;
2026-03-17 23:15:13 +08:00
}
function getObservationSourceTag ( detail : CityDetail ) : string {
const label = String ( detail . current ? . settlement_source_label || "" )
. trim ()
. toUpperCase ();
if ( label ) return label ;
const code = getObservationSourceCode ( detail );
if ( code === "hko" ) return "HKO" ;
if ( code === "cwa" ) return "CWA" ;
2026-03-23 14:39:41 +08:00
if ( code === "noaa" ) return "NOAA" ;
2026-04-17 00:19:47 +08:00
if ( code === "wunderground" ) {
const icao = String ( detail . risk ? . icao || detail . current ? . station_code || "" )
. trim ()
. toUpperCase ();
return icao ? ` ${ icao } METAR` : "METAR" ;
}
2026-03-17 23:15:13 +08:00
if ( code === "mgm" ) return "MGM" ;
return "METAR" ;
}
2026-04-06 21:04:16 +08:00
function getRealtimeObservationTag ( detail : CityDetail ) : string {
const code = getObservationSourceCode ( detail );
if ( code === "wunderground" ) {
const icao = String ( detail . risk ? . icao || "" ). trim (). toUpperCase ();
return icao ? ` ${ icao } METAR` : "METAR" ;
}
return getObservationSourceTag ( detail );
}
2026-03-27 20:58:38 +08:00
function getNoaaStationCode ( detail : CityDetail ) : string {
return String ( detail . current ? . station_code || detail . risk ? . icao || "NOAA" )
. trim ()
. toUpperCase ();
}
function getNoaaStationName ( detail : CityDetail ) : string {
return (
String ( detail . current ? . station_name || "" ). trim () ||
String ( detail . risk ? . airport || "" ). trim () ||
getNoaaStationCode ( detail )
);
}
2026-03-10 04:45:40 +08:00
function normalizeCloudSummary (
cloudDesc : string | null | undefined ,
locale : Locale ,
) : { icon : string ; text : string } {
const raw = String ( cloudDesc || "" ). trim ();
if ( ! raw ) {
return { icon : "🔍" , text : isEnglish ( locale ) ? "Unknown" : "未知" };
}
const lower = raw . toLowerCase ();
if (
raw . includes ( "晴" ) ||
raw . includes ( "晴朗" ) ||
lower . includes ( "clear" ) ||
lower . includes ( "sunny" )
) {
return { icon : "☀️" , text : isEnglish ( locale ) ? "Clear" : "晴朗" };
}
if ( raw . includes ( "阴" ) || lower . includes ( "overcast" )) {
return { icon : "☁️" , text : isEnglish ( locale ) ? "Overcast" : "阴天" };
}
if ( raw . includes ( "多云" ) || lower . includes ( "cloud" )) {
return { icon : "☁️" , text : isEnglish ( locale ) ? "Cloudy" : "多云" };
}
if ( raw . includes ( "少云" ) || lower . includes ( "few" )) {
return { icon : "🌤️" , text : isEnglish ( locale ) ? "Mostly clear" : "少云" };
}
if ( raw . includes ( "散云" ) || lower . includes ( "scattered" )) {
return { icon : "⛅" , text : isEnglish ( locale ) ? "Partly cloudy" : "散云" };
}
return { icon : "🔍" , text : raw };
}
export function translateMetar ( code? : string | null , locale : Locale = "zh-CN" ) {
2026-03-09 10:36:03 +08:00
if ( ! code ) return null ;
2026-03-10 04:45:40 +08:00
const metarCode = String ( code );
2026-03-09 10:36:03 +08:00
for ( const [ key , value ] of Object . entries ( METAR_WX_MAP )) {
2026-03-10 04:45:40 +08:00
if ( metarCode . includes ( key )) {
return {
icon : value.icon ,
label : isEnglish ( locale ) ? value.en : value.zh ,
};
}
2026-03-09 10:36:03 +08:00
}
2026-03-10 04:45:40 +08:00
return { icon : "🔍" , label : metarCode };
2026-03-09 10:36:03 +08:00
}
2026-03-10 04:45:40 +08:00
export function getRiskBadgeLabel (
level? : string | null ,
locale : Locale = "zh-CN" ,
) {
if ( isEnglish ( locale )) {
return (
{
high : "🔴 High Risk" ,
low : "🟢 Low Risk" ,
medium : "🟠 Medium Risk" ,
}[ String ( level || "low" )] || "Unknown Risk"
);
}
2026-03-09 10:36:03 +08:00
return (
{
high : "🔴 高风险" ,
low : "🟢 低风险" ,
2026-03-10 04:45:40 +08:00
medium : "🟠 中风险" ,
2026-03-09 10:36:03 +08:00
}[ String ( level || "low" )] || "未知风险"
);
}
2026-03-10 04:45:40 +08:00
export function getWeatherSummary ( detail : CityDetail , locale : Locale = "zh-CN" ) {
2026-03-09 10:36:03 +08:00
const current = detail . current || {};
2026-03-10 04:45:40 +08:00
const cloud = normalizeCloudSummary ( current . cloud_desc , locale );
let weatherText = cloud . text ;
let weatherIcon = cloud . icon ;
2026-03-09 10:36:03 +08:00
if ( current . wx_desc ) {
2026-03-10 04:45:40 +08:00
const translated = translateMetar ( current . wx_desc , locale );
2026-03-09 10:36:03 +08:00
if ( translated ) {
weatherText = translated . label ;
weatherIcon = translated . icon ;
}
}
return { weatherIcon , weatherText };
}
2026-04-08 10:33:58 +08:00
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 ;
}
2026-04-13 16:35:22 +08:00
function findNearestTimeIndex (
times : string [],
targetTime? : string | null ,
) {
const targetMinutes = hmToMinutes ( targetTime );
if ( targetMinutes == null || ! times . length ) return - 1 ;
let nearestIndex = - 1 ;
let nearestDelta = Number . POSITIVE_INFINITY ;
times . forEach (( time , index ) => {
const minute = hmToMinutes ( time );
if ( minute == null ) return ;
const delta = Math . abs ( minute - targetMinutes );
if ( delta < nearestDelta ) {
nearestDelta = delta ;
nearestIndex = index ;
}
});
return nearestIndex ;
}
function buildTemperatureTickLabels ( times : string []) {
const lastIndex = Math . max ( 0 , times . length - 1 );
return times . map (( time , index ) => {
if ( index === 0 || index === lastIndex ) return time ;
const minute = hmToMinutes ( time );
if ( minute == null ) return "" ;
const hour = Math . floor ( minute / 60 );
const minutePart = minute % 60 ;
if ( minutePart !== 0 ) return "" ;
return hour % 2 === 0 ? time : "" ;
});
}
function buildSeriesPoints (
times : string [],
values : Array < number | null | undefined >,
) {
return times
. map (( time , index ) => {
const x = hmToMinutes ( time );
const y = values [ index ];
return x != null && y != null && Number . isFinite ( Number ( y ))
? { index , labelTime : time , x , y : Number ( y ) }
: null ;
})
. filter (
( point ) : point is { index : number ; labelTime : string ; x : number ; y : number } =>
point != null ,
);
}
function buildObservationPoints ( items : Array < { time? : string ; temp? : number | null } > ) {
return items
. map (( item ) => {
const labelTime = normalizeHm ( String ( item . time || "" ));
const x = hmToMinutes ( labelTime );
const y = item . temp ;
return x != null && y != null && Number . isFinite ( Number ( y ))
? { labelTime : labelTime || "" , x , y : Number ( y ) }
: null ;
})
. filter (( point ) : point is { labelTime : string ; x : number ; y : number } => point != null );
}
2026-04-16 00:16:33 +08:00
function sortObservationItemsByTime < T extends { time ?: string | null }>( items : T []) {
return [... items ]. sort (( left , right ) => {
const leftMinutes = hmToMinutes ( left . time );
const rightMinutes = hmToMinutes ( right . time );
if ( leftMinutes == null && rightMinutes == null ) return 0 ;
if ( leftMinutes == null ) return 1 ;
if ( rightMinutes == null ) return - 1 ;
return leftMinutes - rightMinutes ;
});
}
2026-04-17 00:04:53 +08:00
function dedupeObservationItems < T extends { temp ?: number | null ; time ?: string | null }>(
items : T [],
) {
const byTime = new Map < string , T >();
items . forEach (( item ) => {
const time = normalizeHm ( item . time );
const value = Number ( item . temp );
if ( ! time || ! Number . isFinite ( value )) return ;
const existing = byTime . get ( time );
if ( ! existing || Number ( item . temp ) >= Number ( existing . temp )) {
byTime . set ( time , { ... item , time });
}
});
return sortObservationItemsByTime ([... byTime . values ()]);
}
function looksLikeForecastMirror (
observations : Array < { temp? : number | null ; time? : string | null } > ,
forecastTimes : string [],
forecastValues : Array < number | null | undefined >,
) {
const unique = dedupeObservationItems ( observations );
if ( unique . length < 6 || forecastTimes . length < 6 ) return false ;
if ( unique . length < Math . max ( 6 , Math . floor ( forecastTimes . length * 0.4 ))) {
return false ;
}
let compared = 0 ;
let exactMatches = 0 ;
unique . forEach (( item ) => {
const minute = hmToMinutes ( item . time );
const observed = Number ( item . temp );
if ( minute == null || ! Number . isFinite ( observed )) return ;
const expected = interpolateSeriesAtMinutes (
forecastTimes ,
forecastValues ,
minute ,
);
if ( expected == null || ! Number . isFinite ( expected )) return ;
compared += 1 ;
if ( Math . abs ( observed - expected ) <= 0.05 ) {
exactMatches += 1 ;
}
});
return compared >= 6 && exactMatches / compared >= 0.65 ;
}
2026-04-16 15:29:26 +08:00
function normalizeObservationTimeForChart (
value : unknown ,
detail : CityDetail ,
) {
const raw = String ( value || "" ). trim ();
if ( raw && ! raw . includes ( "T" )) {
return normalizeHm ( raw ) || raw ;
}
return normalizeHm ( detail . local_time ) || normalizeHm ( raw ) || raw ;
}
function buildCurrentObservationFallback (
detail : CityDetail ,
) : Array < { time? : string ; temp? : number | null ; sourceLabel? : string | null } > {
const candidates : Array < {
sourceLabel? : string | null ;
temp? : number | null ;
time? : string | null ;
} > = [
{
sourceLabel : detail.current?.settlement_source_label ,
temp : detail.current?.temp ,
time : detail.current?.obs_time || detail . current ? . report_time ,
},
{
sourceLabel : detail.airport_primary?.source_label || "METAR" ,
temp : detail.airport_primary?.temp ,
time : detail.airport_primary?.obs_time || detail . airport_primary ? . report_time ,
},
{
sourceLabel : detail.airport_current?.source_label || "METAR" ,
temp : detail.airport_current?.temp ,
time : detail.airport_current?.obs_time || detail . airport_current ? . report_time ,
},
{
sourceLabel :
detail.center_station_candidate?.source_label ||
detail . center_station_candidate ? . source_code ,
temp : detail.center_station_candidate?.temp ,
time : String (( detail . center_station_candidate as Record < string , unknown > | null | undefined ) ? . obs_time || "" ),
},
{
sourceLabel :
detail.official_nearby?. [ 0 ] ? . source_label ||
detail . official_nearby ? .[ 0 ] ? . source_code ,
temp : detail.official_nearby?. [ 0 ] ? . temp ,
time : String (( detail . official_nearby ? .[ 0 ] as Record < string , unknown > | null | undefined ) ? . obs_time || "" ),
},
{
sourceLabel :
detail.mgm_nearby?. [ 0 ] ? . source_label ||
detail . mgm_nearby ? .[ 0 ] ? . source_code ,
temp : detail.mgm_nearby?. [ 0 ] ? . temp ,
time : String (( detail . mgm_nearby ? .[ 0 ] as Record < string , unknown > | null | undefined ) ? . obs_time || "" ),
},
];
const first = candidates . find (( item ) => {
const numeric = Number ( item . temp );
return Number . isFinite ( numeric );
});
if ( ! first ) return [];
return [
{
sourceLabel : first.sourceLabel ,
temp : Number ( first . temp ),
time : normalizeObservationTimeForChart ( first . time , detail ),
},
];
}
2026-04-08 10:33:58 +08:00
function interpolateSeriesAtMinutes (
times : string [],
values : Array < number | null | undefined >,
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 ,
};
}
2026-03-10 04:45:40 +08:00
export function getHeroMetaItems ( detail : CityDetail , locale : Locale = "zh-CN" ) {
2026-03-09 10:36:03 +08:00
const current = detail . current || {};
const parts : string [] = [];
2026-04-06 21:04:16 +08:00
const sourceTag = getRealtimeObservationTag ( detail );
2026-03-30 19:03:13 +08:00
const suppressAnkaraMgmObservation = isTurkishMgmCity ( detail );
2026-03-09 10:36:03 +08:00
if ( current . obs_time ) {
const ageText =
current . obs_age_min != null && current . obs_age_min >= 30
2026-03-10 04:45:40 +08:00
? isEnglish ( locale )
? ` ( ${ current . obs_age_min } min ago)`
: `( ${ current . obs_age_min } 分钟前)`
2026-03-09 10:36:03 +08:00
: "" ;
2026-03-17 23:15:13 +08:00
parts . push ( `✈️ ${ sourceTag } ${ current . obs_time }${ ageText } ` );
2026-03-09 10:36:03 +08:00
}
if ( current . wx_desc ) {
2026-03-10 04:45:40 +08:00
const translated = translateMetar ( current . wx_desc , locale );
2026-03-09 10:36:03 +08:00
if ( translated ) {
parts . push ( ` ${ translated . icon } ${ translated . label } ` );
}
} else if ( current . cloud_desc ) {
2026-03-10 04:45:40 +08:00
const cloud = normalizeCloudSummary ( current . cloud_desc , locale );
parts . push ( ` ${ cloud . icon } ${ cloud . text } ` );
2026-03-09 10:36:03 +08:00
}
if ( current . wind_speed_kt != null ) {
parts . push ( `💨 ${ current . wind_speed_kt } kt` );
}
if ( current . visibility_mi != null ) {
parts . push ( `👁️ ${ current . visibility_mi } mi` );
}
2026-03-29 20:53:48 +08:00
if ( ! suppressAnkaraMgmObservation && detail . mgm ? . temp != null ) {
2026-03-09 10:36:03 +08:00
const timeMatch = detail . mgm . time ? . match ( /T?(\d{2}:\d{2})/ );
const timeText = timeMatch ? ` @ ${ timeMatch [ 1 ] } ` : "" ;
2026-03-10 04:45:40 +08:00
parts . push (
isEnglish ( locale )
? `🛰 MGM Obs: ${ detail . mgm . temp }${ detail . temp_symbol }${ timeText } `
: `🛰 MGM 实测: ${ detail . mgm . temp }${ detail . temp_symbol }${ timeText } ` ,
);
2026-03-09 10:36:03 +08:00
}
const trend = detail . trend || {};
if ( trend . is_dead_market ) {
2026-03-10 04:45:40 +08:00
parts . push ( isEnglish ( locale ) ? "☠️ Flat market" : "☠️ 死盘" );
2026-03-09 10:36:03 +08:00
} else if ( trend . direction && trend . direction !== "unknown" ) {
2026-03-10 04:45:40 +08:00
const labels : Record < string , string > = isEnglish ( locale )
? {
falling : "📉 Cooling" ,
mixed : "📊 Choppy" ,
rising : "📈 Warming" ,
stagnant : "⏸ Flat" ,
}
: {
falling : "📉 降温中" ,
mixed : "📊 波动中" ,
rising : "📈 升温中" ,
stagnant : "⏸ 持平" ,
};
2026-03-09 10:36:03 +08:00
parts . push ( labels [ trend . direction ] || trend . direction );
}
return parts ;
}
2026-03-10 04:45:40 +08:00
export function getTemperatureChartData (
detail : CityDetail ,
locale : Locale = "zh-CN" ,
) {
2026-03-09 10:36:03 +08:00
const hourly = detail . hourly || {};
const times = hourly . times || [];
const temps = hourly . temps || [];
2026-03-30 19:03:13 +08:00
const suppressAnkaraMgmObservation = isTurkishMgmCity ( detail );
2026-03-09 10:36:03 +08:00
if ( ! times . length ) return null ;
2026-04-13 16:35:22 +08:00
const currentIndex = findNearestTimeIndex ( times , detail . local_time );
2026-03-09 10:36:03 +08:00
const omMax = detail . forecast ? . today_high ;
const debMax = detail . deb ? . prediction ;
const offset =
debMax != null && omMax != null ? Number ( debMax ) - Number ( omMax ) : 0 ;
const debTemps = temps . map (( temp ) =>
temp != null ? Number (( temp + offset ). toFixed ( 1 )) : null ,
);
const debPast = debTemps . map (( temp , index ) =>
currentIndex >= 0 && index <= currentIndex ? temp : null ,
);
const debFuture = debTemps . map (( temp , index ) =>
currentIndex < 0 || index >= currentIndex ? temp : null ,
);
2026-04-06 21:04:16 +08:00
const observationTag = getRealtimeObservationTag ( detail );
2026-03-17 23:15:13 +08:00
const observationCode = getObservationSourceCode ( detail );
const settlementSource =
2026-03-25 17:14:25 +08:00
observationCode === "hko" ||
observationCode === "cwa" ||
observationCode === "noaa" ||
observationCode === "wunderground" ;
2026-04-16 23:55:43 +08:00
const useSettlementObservationSource = settlementSource ;
2026-03-22 22:43:06 +08:00
const officialObservationSource =
2026-04-05 07:24:09 +08:00
useSettlementObservationSource
2026-03-22 22:43:06 +08:00
? detail . settlement_today_obs ? . length
? detail.settlement_today_obs
: detail.current?.obs_time && detail . current ? . temp != null
? [{ time : detail.current.obs_time , temp : detail.current.temp }]
: []
: [];
2026-04-16 15:29:26 +08:00
const currentObservationFallback = buildCurrentObservationFallback ( detail );
2026-04-16 17:34:39 +08:00
const minPlausibleObservationTemp = (() => {
const name = String ( detail . name || "" ). trim (). toLowerCase ();
const icao = String ( detail . risk ? . icao || "" ). trim (). toUpperCase ();
if ( name === "karachi" || name === "masroor air base" || icao === "OPKC" || icao === "OPMR" ) {
return detail . temp_symbol === "°F" ? 41 : 5 ;
}
return null ;
})();
const filterPlausibleObservations = < T extends { temp ?: number | null }>(
rows? : T [] | null ,
) =>
( Array . isArray ( rows ) ? rows : []). filter (( row ) => {
const value = Number ( row ? . temp );
if ( ! Number . isFinite ( value )) return false ;
return minPlausibleObservationTemp == null || value >= minPlausibleObservationTemp ;
});
const plausibleMetarTodayObs = filterPlausibleObservations ( detail . metar_today_obs );
const plausibleTrendRecent = filterPlausibleObservations ( detail . trend ? . recent );
const plausibleCurrentFallback = filterPlausibleObservations ( currentObservationFallback );
const metarObservationSource = plausibleMetarTodayObs . length
? plausibleMetarTodayObs
: plausibleTrendRecent.length
? plausibleTrendRecent
: plausibleCurrentFallback ;
2026-04-16 15:29:26 +08:00
const usingCurrentObservationFallback =
2026-04-16 17:34:39 +08:00
! plausibleMetarTodayObs . length &&
! plausibleTrendRecent . length &&
plausibleCurrentFallback . length > 0 ;
2026-04-16 15:29:26 +08:00
const currentFallbackTag =
currentObservationFallback [ 0 ] ? . sourceLabel ||
getObservationSourceTag ( detail );
2026-04-05 07:24:09 +08:00
const allowMetarFallback = settlementSource && observationCode !== "hko" ;
2026-03-22 22:43:06 +08:00
const shouldUseMetarFallback =
2026-03-23 21:22:41 +08:00
allowMetarFallback &&
2026-03-22 22:43:06 +08:00
officialObservationSource . length > 0 &&
officialObservationSource . length < 3 &&
metarObservationSource . length >= 3 ;
2026-04-17 00:04:53 +08:00
let usingMetarObservationSource =
2026-04-16 23:55:43 +08:00
! useSettlementObservationSource || shouldUseMetarFallback ;
2026-04-17 00:04:53 +08:00
let observationSource = useSettlementObservationSource
2026-03-22 22:43:06 +08:00
? shouldUseMetarFallback
? metarObservationSource
: officialObservationSource
: metarObservationSource ;
2026-04-17 00:04:53 +08:00
let usingMirrorFallback = false ;
if ( looksLikeForecastMirror ( observationSource , times , debTemps )) {
const fallbackCandidates = [
plausibleTrendRecent ,
plausibleCurrentFallback ,
metarObservationSource ,
];
const fallback = fallbackCandidates . find (
( candidate ) =>
candidate . length > 0 &&
candidate !== observationSource &&
! looksLikeForecastMirror ( candidate , times , debTemps ),
);
if ( fallback ) {
observationSource = fallback ;
usingMetarObservationSource = fallback === metarObservationSource ;
usingMirrorFallback = true ;
}
}
observationSource = dedupeObservationItems ( observationSource );
2026-04-05 07:24:09 +08:00
const airportMetarSource : Array < { time? : string ; temp? : number | null } > = [];
2026-03-22 22:43:06 +08:00
const metarFallbackTag = (() => {
const icao = String ( detail . risk ? . icao || "" ). trim (). toUpperCase ();
if ( ! icao ) return "METAR" ;
return ` ${ icao } METAR` ;
})();
const observationDisplayTag =
2026-04-16 15:29:26 +08:00
usingCurrentObservationFallback
? String ( currentFallbackTag ). toUpperCase ()
2026-04-16 23:55:43 +08:00
: observationCode === "wunderground" && usingMetarObservationSource
2026-04-06 21:04:16 +08:00
? metarFallbackTag
2026-04-16 23:55:43 +08:00
: observationCode === "wunderground"
2026-04-17 00:19:47 +08:00
? metarFallbackTag
2026-04-06 21:04:16 +08:00
: useSettlementObservationSource && shouldUseMetarFallback
2026-03-23 14:39:41 +08:00
? metarFallbackTag
: observationCode === "noaa"
2026-03-27 20:58:38 +08:00
? `NOAA ${ getNoaaStationCode ( detail ) } `
2026-03-23 14:39:41 +08:00
: observationTag ;
2026-03-09 10:36:03 +08:00
2026-03-17 23:15:13 +08:00
const metarPoints = new Array ( times . length ). fill ( null );
observationSource . forEach (( item ) => {
2026-04-13 16:35:22 +08:00
const index = findNearestTimeIndex ( times , String ( item . time || "" ));
2026-03-22 23:28:48 +08:00
const temp = item . temp ?? null ;
if ( index >= 0 && temp != null ) {
const existing = metarPoints [ index ];
// Multiple reports can land in the same hour bucket. Keep the peak
// value so an intrahour high is not hidden by a later weaker report.
metarPoints [ index ] =
existing == null ? temp : Math.max ( Number ( existing ), Number ( temp ));
2026-03-09 10:36:03 +08:00
}
});
2026-03-25 18:03:56 +08:00
const airportMetarPoints = new Array ( times . length ). fill ( null );
airportMetarSource . forEach (( item ) => {
2026-04-13 16:35:22 +08:00
const index = findNearestTimeIndex ( times , String ( item . time || "" ));
2026-03-25 18:03:56 +08:00
const temp = item . temp ?? null ;
if ( index >= 0 && temp != null ) {
const existing = airportMetarPoints [ index ];
airportMetarPoints [ index ] =
existing == null ? temp : Math.max ( Number ( existing ), Number ( temp ));
}
});
2026-03-09 10:36:03 +08:00
const mgmPoints = new Array ( times . length ). fill ( null );
2026-03-29 20:53:48 +08:00
if (
! suppressAnkaraMgmObservation &&
detail . mgm ? . temp != null &&
detail . mgm ? . time
) {
2026-04-13 16:35:22 +08:00
const index = findNearestTimeIndex ( times , detail . mgm . time );
if ( index >= 0 ) {
mgmPoints [ index ] = detail . mgm . temp ;
2026-03-09 10:36:03 +08:00
}
}
const mgmHourlyPoints = new Array ( times . length ). fill ( null );
let hasMgmHourly = false ;
detail . mgm ? . hourly ? . forEach (( item ) => {
2026-04-13 16:35:22 +08:00
const index = findNearestTimeIndex ( times , String ( item . time || "" ));
2026-03-09 10:36:03 +08:00
if ( index >= 0 ) {
mgmHourlyPoints [ index ] = item . temp ?? null ;
hasMgmHourly = true ;
}
});
const allValues = [
... debTemps . filter (( value ) => value != null ),
... metarPoints . filter (( value ) => value != null ),
2026-03-25 18:03:56 +08:00
... airportMetarPoints . filter (( value ) => value != null ),
2026-03-09 10:36:03 +08:00
... mgmPoints . filter (( value ) => value != null ),
... mgmHourlyPoints . filter (( value ) => value != null ),
] as number [];
if ( ! allValues . length ) return null ;
const min = Math . floor ( Math . min (... allValues )) - 1 ;
const max = Math . ceil ( Math . max (... allValues )) + 1 ;
2026-03-24 03:13:54 +08:00
const tafMarkersRaw = Array . isArray ( detail . taf ? . signal ? . markers )
? detail . taf ? . signal ? . markers || []
: [];
2026-03-25 21:46:33 +08:00
const normalizeHm = ( value : unknown ) : 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" ) } ` ;
};
const hmToMinutes = ( value : string | null ) => {
if ( ! value ) return null ;
const [ hourPart , minutePart ] = value . split ( ":" );
const hour = Number . parseInt ( hourPart || "" , 10 );
const minute = Number . parseInt ( minutePart || "" , 10 );
if (
! Number . isFinite ( hour ) ||
! Number . isFinite ( minute ) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return null ;
}
return hour * 60 + minute ;
};
const currentMinutes = hmToMinutes ( normalizeHm ( detail . local_time ));
const peakFirstHour = Number ( detail . peak ? . first_h );
const peakLastHour = Number ( detail . peak ? . last_h );
const peakWindowStartMinutes =
Number . isFinite ( peakFirstHour ) && peakFirstHour >= 0
? Math . max ( 0 , ( peakFirstHour - 2 ) * 60 )
: null ;
const peakWindowEndMinutes =
Number . isFinite ( peakLastHour ) && peakLastHour >= peakFirstHour
? Math . min ( 23 * 60 + 59 , ( peakLastHour + 1 ) * 60 )
: null ;
2026-03-24 03:13:54 +08:00
const tafMarkerValue = max - 0.4 ;
const tafMarkerPoints = new Array ( times . length ). fill ( null );
2026-03-25 21:46:33 +08:00
const tafCurrentMarkerPoints = new Array ( times . length ). fill ( null );
const tafPeakWindowMarkerPoints = new Array ( times . length ). fill ( null );
const sameMarker = (
left :
| { markerType? : string | null ; startLocal? : string | null ; endLocal? : string | null }
| null
| undefined ,
right :
| { markerType? : string | null ; startLocal? : string | null ; endLocal? : string | null }
| null
| undefined ,
) =>
!! left &&
!! right &&
String ( left . markerType || "" ) === String ( right . markerType || "" ) &&
String ( left . startLocal || "" ) === String ( right . startLocal || "" ) &&
String ( left . endLocal || "" ) === String ( right . endLocal || "" );
2026-03-24 03:13:54 +08:00
const tafMarkers = tafMarkersRaw
. map (( marker ) => {
const labelTime = String ( marker ? . label_time || "" ). trim ();
2026-04-13 16:35:22 +08:00
const index = findNearestTimeIndex ( times , labelTime );
2026-03-24 03:13:54 +08:00
if ( index >= 0 ) {
tafMarkerPoints [ index ] = tafMarkerValue ;
}
return {
2026-03-24 03:44:12 +08:00
displayType : formatTafMarkerType (
String ( marker ? . marker_type || "" ). trim (),
locale ,
),
2026-03-24 03:13:54 +08:00
endLocal : String ( marker ? . end_local || "" ). trim (),
index ,
labelTime ,
markerType : String ( marker ? . marker_type || "" ). trim (),
startLocal : String ( marker ? . start_local || "" ). trim (),
summary :
isEnglish ( locale )
? String ( marker ? . summary_en || "" ). trim ()
: String ( marker ? . summary_zh || "" ). trim (),
2026-03-25 21:46:33 +08:00
isCurrent : false ,
isPeakWindow : false ,
2026-03-24 03:13:54 +08:00
suppressionLevel : String ( marker ? . suppression_level || "" ). trim (),
};
})
. filter (( marker ) => marker . index >= 0 );
2026-03-25 21:46:33 +08:00
const currentTafMarker =
currentMinutes !== null
? tafMarkers . find (( marker ) => {
const start = hmToMinutes ( normalizeHm ( marker . startLocal ));
const end = hmToMinutes ( normalizeHm ( marker . endLocal ));
return start !== null && end !== null && currentMinutes >= start && currentMinutes <= end ;
}) || null
: null ;
const nextTafMarker =
currentMinutes !== null && ! currentTafMarker
? tafMarkers . find (( marker ) => {
const start = hmToMinutes ( normalizeHm ( marker . startLocal ));
return start !== null && start > currentMinutes ;
}) || null
: null ;
const peakWindowTafMarker =
peakWindowStartMinutes !== null && peakWindowEndMinutes !== null
? tafMarkers . find (( marker ) => {
const start = hmToMinutes ( normalizeHm ( marker . startLocal ));
const end = hmToMinutes ( normalizeHm ( marker . endLocal ));
return (
start !== null &&
end !== null &&
start <= peakWindowEndMinutes &&
end >= peakWindowStartMinutes
);
}) || null
: null ;
tafMarkers . forEach (( marker ) => {
2026-03-25 21:54:34 +08:00
const isPrimaryTafMarker =
sameMarker ( marker , currentTafMarker ) || sameMarker ( marker , nextTafMarker );
const isPeakReferenceMarker = sameMarker ( marker , peakWindowTafMarker );
if ( isPrimaryTafMarker ) {
2026-03-25 21:46:33 +08:00
marker . isCurrent = true ;
tafCurrentMarkerPoints [ marker . index ] = tafMarkerValue ;
}
2026-03-25 21:54:34 +08:00
if ( isPeakReferenceMarker ) {
2026-03-25 21:46:33 +08:00
marker . isPeakWindow = true ;
2026-03-25 21:54:34 +08:00
if ( ! isPrimaryTafMarker ) {
tafPeakWindowMarkerPoints [ marker . index ] = tafMarkerValue - 0.15 ;
}
2026-03-25 21:46:33 +08:00
}
});
const formatTafLegendMarker = (
marker :
| { displayType? : string | null ; startLocal? : string | null ; endLocal? : string | null ; summary? : string | null }
| null
| undefined ,
) => {
if ( ! marker ) return "" ;
const range = ` ${ marker . startLocal || "--:--" } - ${ marker . endLocal || "--:--" } ` ;
const status = String ( marker . summary || "" ). trim ();
return status
? ` ${ marker . displayType || "" } ${ range } ${ status } ` . trim ()
: ` ${ marker . displayType || "" } ${ range } ` . trim ();
};
2026-03-09 10:36:03 +08:00
const legendParts : string [] = [];
2026-03-29 20:53:48 +08:00
if ( ! suppressAnkaraMgmObservation && detail . mgm ? . temp != null ) {
2026-03-09 10:36:03 +08:00
legendParts . push ( `MGM: ${ detail . mgm . temp }${ detail . temp_symbol } ` );
}
if ( ! hasMgmHourly && debMax != null && omMax != null && Math . abs ( offset ) > 0.3 ) {
const sign = offset > 0 ? "+" : "" ;
2026-03-10 04:45:40 +08:00
legendParts . push (
isEnglish ( locale )
? `DEB offset ${ sign }${ offset . toFixed ( 1 ) }${ detail . temp_symbol } vs OM`
: `DEB 偏移 ${ sign }${ offset . toFixed ( 1 ) }${ detail . temp_symbol } vs OM` ,
);
2026-03-09 10:36:03 +08:00
}
if ( hasMgmHourly ) {
2026-03-10 04:45:40 +08:00
legendParts . push (
isEnglish ( locale )
? "Using MGM hourly forecast to replace DEB curve"
: "已使用 MGM 小时预报替代 DEB 曲线" ,
);
2026-03-09 10:36:03 +08:00
}
2026-03-17 23:15:13 +08:00
if (( detail . trend ? . recent ? . length || 0 ) > 0 || observationSource . length > 0 ) {
2026-04-16 00:16:33 +08:00
const recentData = sortObservationItemsByTime (
2026-03-17 23:15:13 +08:00
observationSource . length > 0
? [... observationSource ]
2026-04-16 00:16:33 +08:00
: [...( detail . trend ? . recent || [])],
);
2026-03-17 23:15:13 +08:00
const recentText = recentData
2026-04-16 00:16:33 +08:00
. slice ( - 4 )
2026-03-09 10:36:03 +08:00
. map (( item ) => ` ${ item . temp }${ detail . temp_symbol } @ ${ item . time } ` )
. join ( " -> " );
2026-03-22 22:43:06 +08:00
legendParts . push ( ` ${ observationDisplayTag } : ${ recentText } ` );
}
2026-03-25 18:03:56 +08:00
if ( airportMetarSource . length > 0 ) {
2026-04-16 00:16:33 +08:00
const airportRecentText = sortObservationItemsByTime ([... airportMetarSource ])
2026-03-25 18:03:56 +08:00
. slice ( - 4 )
. map (( item ) => ` ${ item . temp }${ detail . temp_symbol } @ ${ item . time } ` )
. join ( " -> " );
legendParts . push (
isEnglish ( locale )
? ` ${ metarFallbackTag } : ${ airportRecentText } `
: ` ${ metarFallbackTag } : ${ airportRecentText } ` ,
);
}
2026-03-22 22:43:06 +08:00
if ( shouldUseMetarFallback ) {
legendParts . push (
isEnglish ( locale )
? `Official ${ observationTag } feed is sparse today, so the continuous observation line switches to ${ metarFallbackTag } .`
: `今日官方 ${ observationTag } 点位较稀疏,连续实测线改用 ${ metarFallbackTag } 。` ,
);
2026-04-17 00:04:53 +08:00
}
if ( usingMirrorFallback ) {
legendParts . push (
isEnglish ( locale )
? "Dense observation feed matched the forecast curve exactly, so it was ignored for this chart refresh."
: "本次高密度观测源与预测曲线逐点重合,已忽略该异常源。" ,
);
2026-03-23 21:22:41 +08:00
} else if ( observationCode === "hko" ) {
legendParts . push (
isEnglish ( locale )
2026-03-27 20:58:38 +08:00
? "This city uses HKO official readings. The chart keeps official HKO points instead of switching to airport METAR."
: "该城市按 HKO 官方读数展示;图中保留 HKO 官方点位,不切换到机场 METAR 连续线。" ,
2026-03-23 21:22:41 +08:00
);
2026-03-23 14:39:41 +08:00
} else if ( observationCode === "noaa" ) {
2026-03-27 20:58:38 +08:00
const noaaCode = getNoaaStationCode ( detail );
2026-03-23 14:39:41 +08:00
legendParts . push (
isEnglish ( locale )
2026-03-27 20:58:38 +08:00
? `This city settles on NOAA ${ noaaCode } using the finalized highest rounded whole-degree Celsius Temp reading; the plotted line is a settlement reference.`
: `该城市按 NOAA ${ noaaCode } 最终完成质控后的最高整度摄氏 Temp 读数结算;图中曲线仅作为结算参考线。` ,
2026-03-23 14:39:41 +08:00
);
2026-03-09 10:36:03 +08:00
}
2026-03-24 03:13:54 +08:00
if ( tafMarkers . length ) {
2026-03-25 21:46:33 +08:00
const primaryTafMarker = currentTafMarker || nextTafMarker ;
if ( primaryTafMarker ) {
legendParts . push (
isEnglish ( locale )
? `Current TAF: ${ formatTafLegendMarker ( primaryTafMarker ) } `
: `当前 TAF: ${ formatTafLegendMarker ( primaryTafMarker ) } ` ,
);
}
if ( peakWindowTafMarker && ! sameMarker ( peakWindowTafMarker , primaryTafMarker )) {
legendParts . push (
isEnglish ( locale )
? `Peak-window TAF: ${ formatTafLegendMarker ( peakWindowTafMarker ) } `
: `峰值窗口 TAF: ${ formatTafLegendMarker ( peakWindowTafMarker ) } ` ,
);
}
2026-03-24 03:13:54 +08:00
legendParts . push (
isEnglish ( locale )
2026-03-25 21:46:33 +08:00
? "Use the current TAF segment as primary; peak-window segments are reference only."
: "以当前 TAF 时段为准,峰值窗口时段仅作参考。" ,
2026-03-24 03:13:54 +08:00
);
}
2026-03-09 10:36:03 +08:00
2026-04-13 16:35:22 +08:00
const debPastSeries = buildSeriesPoints ( times , debPast );
const debFutureSeries = buildSeriesPoints ( times , debFuture );
const tempsSeries = buildSeriesPoints ( times , temps );
const mgmHourlySeries = buildSeriesPoints ( times , mgmHourlyPoints );
const metarSeries = buildObservationPoints ( observationSource );
const airportMetarSeries = buildObservationPoints ( airportMetarSource );
const mgmSeries =
! suppressAnkaraMgmObservation && detail . mgm ? . temp != null && detail . mgm ? . time
? buildObservationPoints ([{ time : detail.mgm.time , temp : detail.mgm.temp }])
: [];
const tafCurrentMarkerSeries = tafMarkers
. filter (( marker ) => marker . isCurrent )
. map (( marker ) => ({
marker ,
x : hmToMinutes ( marker . labelTime ) ?? 0 ,
y : tafMarkerValue ,
}))
. filter (( point ) => point . x > 0 );
const tafPeakWindowMarkerSeries = tafMarkers
. filter (( marker ) => marker . isPeakWindow && ! marker . isCurrent )
. map (( marker ) => ({
marker ,
x : hmToMinutes ( marker . labelTime ) ?? 0 ,
y : tafMarkerValue - 0.15 ,
}))
. filter (( point ) => point . x > 0 );
const tafMarkerSeries = tafMarkers
. map (( marker ) => ({
marker ,
x : hmToMinutes ( marker . labelTime ) ?? 0 ,
y : tafMarkerValue ,
}))
. filter (( point ) => point . x > 0 );
const xMin = times . length ? hmToMinutes ( times [ 0 ]) ?? 0 : 0 ;
const xMax = times . length ? hmToMinutes ( times [ times . length - 1 ]) ?? 24 * 60 : 24 * 60 ;
2026-03-09 10:36:03 +08:00
return {
datasets : {
2026-03-25 18:03:56 +08:00
airportMetarPoints ,
2026-04-13 16:35:22 +08:00
airportMetarSeries ,
2026-03-09 10:36:03 +08:00
debFuture ,
2026-04-13 16:35:22 +08:00
debFutureSeries ,
2026-03-09 10:36:03 +08:00
debPast ,
2026-04-13 16:35:22 +08:00
debPastSeries ,
2026-03-09 10:36:03 +08:00
hasMgmHourly ,
metarPoints ,
2026-04-13 16:35:22 +08:00
metarSeries ,
2026-03-09 10:36:03 +08:00
mgmHourlyPoints ,
2026-04-13 16:35:22 +08:00
mgmHourlySeries ,
2026-03-09 10:36:03 +08:00
mgmPoints ,
2026-04-13 16:35:22 +08:00
mgmSeries ,
2026-03-09 10:36:03 +08:00
offset ,
2026-03-25 21:46:33 +08:00
tafCurrentMarkerPoints ,
2026-04-13 16:35:22 +08:00
tafCurrentMarkerSeries ,
2026-03-24 03:13:54 +08:00
tafMarkerPoints ,
2026-04-13 16:35:22 +08:00
tafMarkerSeries ,
2026-03-25 21:46:33 +08:00
tafPeakWindowMarkerPoints ,
2026-04-13 16:35:22 +08:00
tafPeakWindowMarkerSeries ,
2026-03-09 10:36:03 +08:00
temps ,
2026-04-13 16:35:22 +08:00
tempsSeries ,
2026-03-09 10:36:03 +08:00
},
2026-03-23 14:39:41 +08:00
observationLabel :
2026-04-05 07:24:09 +08:00
observationCode === "noaa" &&
2026-03-25 17:14:25 +08:00
! shouldUseMetarFallback
2026-03-23 14:39:41 +08:00
? isEnglish ( locale )
? ` ${ observationDisplayTag } Settlement Reference`
: ` ${ observationDisplayTag } 结算参考`
: isEnglish ( locale )
? ` ${ observationDisplayTag } Observation`
: ` ${ observationDisplayTag } 实况` ,
2026-03-09 10:36:03 +08:00
legendText : legendParts.join ( " | " ),
max ,
min ,
2026-03-24 03:13:54 +08:00
tafMarkers ,
2026-04-13 16:35:22 +08:00
tickLabels : buildTemperatureTickLabels ( times ),
2026-03-09 10:36:03 +08:00
times ,
2026-04-13 16:35:22 +08:00
xMax ,
xMin ,
2026-03-09 10:36:03 +08:00
};
}
export function getProbabilityView ( detail : CityDetail , targetDate? : string | null ) {
const date = targetDate || detail . local_date ;
if ( date === detail . local_date ) {
return {
mu : detail.probabilities?.mu ?? null ,
probabilities : detail.probabilities?.distribution || [],
};
}
const daily = detail . multi_model_daily ? .[ date ];
return {
mu : daily?.deb?.prediction ?? null ,
probabilities : daily?.probabilities || [],
};
}
export function getModelView ( detail : CityDetail , targetDate? : string | null ) {
const date = targetDate || detail . local_date ;
const daily = detail . multi_model_daily ? .[ date ];
if ( daily ) {
return {
deb : daily.deb?.prediction ?? null ,
2026-03-11 10:49:35 +08:00
models : daily.models || {},
2026-03-09 10:36:03 +08:00
};
}
return {
deb : detail.deb?.prediction ?? null ,
2026-03-11 10:49:35 +08:00
models : detail.multi_model || {},
2026-03-09 10:36:03 +08:00
};
}
export function parseAiAnalysis ( analysis : CityDetail [ "ai_analysis" ]) {
const fallback = {
bullets : [] as string [],
summary : "" ,
};
if ( ! analysis ) return fallback ;
if ( typeof analysis === "string" ) {
return {
bullets : [],
summary : analysis.trim (),
};
}
const structured = analysis as AiAnalysisStructured ;
return {
bullets : Array.isArray ( structured . highlights )
? structured.highlights
: Array.isArray ( structured . points )
? structured . points
: [],
summary : structured.summary || structured . text || structured . message || "" ,
};
}
2026-03-24 15:12:49 +08:00
export function getAirportNarrative (
detail : CityDetail ,
locale : Locale = "zh-CN" ,
) {
const parsed = parseAiAnalysis ( detail . ai_analysis );
if ( ! isEnglish ( locale )) return parsed ;
const englishSummary = containsCjk ( parsed . summary ) ? "" : parsed . summary . trim ();
const englishBullets = parsed . bullets
. map (( item ) => String ( item || "" ). trim ())
. filter (( item ) => item && ! containsCjk ( item ));
if ( englishSummary || englishBullets . length > 0 ) {
return {
bullets : englishBullets ,
summary : englishSummary ,
};
}
const sourceLabel =
String ( detail . current ? . settlement_source_label || "" ). trim () ||
String ( detail . risk ? . icao || "" ). trim () ||
String ( detail . risk ? . airport || "" ). trim () ||
String ( detail . display_name || detail . name || "" ). trim () ||
"Airport" ;
const currentTemp = Number ( detail . current ? . temp );
const tempText = Number . isFinite ( currentTemp )
? ` ${ currentTemp }${ detail . temp_symbol || "°C" } `
: null ;
const obsTime = String ( detail . current ? . obs_time || "" ). trim ();
const weatherText = getWeatherSummary ( detail , locale ). weatherText . toLowerCase ();
const windBucket = bucketLabel (
trendBucketFromDir ( detail . current ? . wind_dir ?? null ),
locale ,
);
const windSpeedKt = Number ( detail . current ? . wind_speed_kt );
const tafSummary = String ( detail . taf ? . signal ? . summary_en || "" ). trim ();
const windPhrase = Number . isFinite ( windSpeedKt )
? ` ${ windBucket } around ${ windSpeedKt } kt`
: ` ${ windBucket } prevailing` ;
const summaryParts = [
tempText
? ` ${ sourceLabel } reports ${ tempText }${ obsTime ? ` at ${ obsTime } ` : "" } , ${ weatherText } .`
: ` ${ sourceLabel } reports ${ weatherText }${ obsTime ? ` at ${ obsTime } ` : "" } .` ,
` ${ windPhrase } .` ,
tafSummary ,
]. filter ( Boolean );
const bullets : string [] = [];
const rawMetar = String ( detail . current ? . raw_metar || "" ). trim ();
if ( rawMetar ) {
bullets . push ( `Latest METAR: ${ rawMetar } ` );
}
if ( tafSummary ) {
bullets . push ( `TAF signal: ${ tafSummary } ` );
}
if ( detail . taf ? . raw_taf ) {
bullets . push ( `TAF available for airport-side timing checks.` );
}
return {
bullets ,
summary : summaryParts.join ( " " ),
};
}
2026-03-09 10:36:03 +08:00
export function pickAnkaraNearbyStations ( stations : NearbyStation []) {
const preferredNames = [
"Airport (MGM/17128)" ,
"Ankara (Bölge/Center)" ,
"Ankara (Bolge/Center)" ,
"Etimesgut" ,
"Pursaklar" ,
"Cubuk" ,
"Çubuk" ,
"Kalecik" ,
];
const picks = preferredNames
. map (( name ) => stations . find (( station ) => station ? . name === name ))
. filter ( Boolean ) as NearbyStation [];
return picks . length ? picks : stations ;
}
2026-03-30 19:21:39 +08:00
function distanceKm (
lat1 : number ,
lon1 : number ,
lat2 : number ,
lon2 : number ,
) {
const toRad = ( deg : number ) => ( deg * Math . PI ) / 180 ;
const dLat = toRad ( lat2 - lat1 );
const dLon = toRad ( lon2 - lon1 );
const a =
Math . sin ( dLat / 2 ) ** 2 +
Math . cos ( toRad ( lat1 )) *
Math . cos ( toRad ( lat2 )) *
Math . sin ( dLon / 2 ) ** 2 ;
return 6371 * 2 * Math . atan2 ( Math . sqrt ( a ), Math . sqrt ( 1 - a ));
}
export function pickMapNearbyStations ( detail : CityDetail ) {
2026-04-06 07:21:59 +08:00
const stations = Array . isArray ( detail . official_nearby )
? detail.official_nearby
: Array.isArray ( detail . mgm_nearby )
? detail . mgm_nearby
: [];
2026-03-30 19:21:39 +08:00
const city = String ( detail . name || detail . display_name || "" )
. trim ()
. toLowerCase ();
if ( city === "ankara" ) {
return pickAnkaraNearbyStations ( stations );
}
if ( city === "istanbul" && Number . isFinite ( detail . lat ) && Number . isFinite ( detail . lon )) {
2026-03-30 19:26:52 +08:00
const preferredTokens = [
"havalimani" ,
"havalimanı " ,
"arnavutkoy" ,
"arnavutköy" ,
"liman feneri" ,
];
2026-03-30 19:21:39 +08:00
const scored = stations
. map (( station ) => {
const lat = Number ( station . lat );
const lon = Number ( station . lon );
if ( ! Number . isFinite ( lat ) || ! Number . isFinite ( lon )) {
return null ;
}
const name = String ( station . name || "" ). toLowerCase ();
const preferred = preferredTokens . some (( token ) => name . includes ( token ));
return {
preferred ,
station ,
km : distanceKm ( Number ( detail . lat ), Number ( detail . lon ), lat , lon ),
};
})
. filter ( Boolean ) as Array < {
preferred : boolean ;
station : NearbyStation ;
km : number ;
} > ;
const closePreferred = scored
2026-03-30 19:26:52 +08:00
. filter (( row ) => row . preferred && row . km <= 18 )
2026-03-30 19:21:39 +08:00
. sort (( a , b ) => a . km - b . km )
. map (( row ) => row . station );
const closeFallback = scored
2026-03-30 19:26:52 +08:00
. filter (( row ) => row . km <= 8 )
2026-03-30 19:21:39 +08:00
. sort (( a , b ) => a . km - b . km )
. map (( row ) => row . station );
const merged = [... closePreferred , ... closeFallback ]. filter (
( station , index , list ) =>
list . findIndex (
( row ) =>
row . name === station . name &&
row . lat === station . lat &&
row . lon === station . lon ,
) === index ,
);
2026-03-30 19:26:52 +08:00
return merged . slice ( 0 , 3 );
2026-03-30 19:21:39 +08:00
}
return stations ;
}
2026-03-09 10:36:03 +08:00
export function getFutureSlice ( detail : CityDetail , dateStr : string ) {
const hourly = detail . hourly_next_48h || {};
const times = hourly . times || [];
const slice : Array < {
cloudCover : number | null ;
dewPoint : number | null ;
label : string ;
precipProb : number | null ;
pressure : number | null ;
radiation : number | null ;
temp : number | null ;
time : string ;
windDir : number | null ;
windSpeed : number | null ;
} > = [];
for ( let index = 0 ; index < times . length ; index += 1 ) {
const timestamp = times [ index ];
if ( ! timestamp || ! String ( timestamp ). startsWith ( dateStr )) continue ;
slice . push ({
cloudCover : hourly.cloud_cover?. [ index ] ?? null ,
dewPoint : hourly.dew_point?. [ index ] ?? null ,
label : String ( timestamp ). split ( "T" )[ 1 ] ? . slice ( 0 , 5 ) || timestamp ,
precipProb : hourly.precipitation_probability?. [ index ] ?? null ,
pressure : hourly.pressure_msl?. [ index ] ?? null ,
radiation : hourly.radiation?. [ index ] ?? null ,
temp : hourly.temps?. [ index ] ?? null ,
time : timestamp ,
windDir : hourly.wind_direction_10m?. [ index ] ?? null ,
windSpeed : hourly.wind_speed_10m?. [ index ] ?? null ,
});
}
return slice ;
}
function trendBucketFromDir ( direction? : number | null ) {
const value = Number ( direction );
if ( ! Number . isFinite ( value )) return null ;
if ( value >= 135 && value <= 240 ) return "southerly" ;
if ( value >= 290 || value <= 45 ) return "northerly" ;
if ( value > 45 && value < 135 ) return "easterly" ;
return "westerly" ;
}
2026-03-10 04:45:40 +08:00
function bucketLabel ( bucket : string | null , locale : Locale = "zh-CN" ) {
if ( isEnglish ( locale )) {
return (
{
southerly : "S / SW wind" ,
northerly : "N / NW wind" ,
easterly : "E wind" ,
westerly : "W wind" ,
}[ bucket || "" ] || "Unknown wind direction"
);
}
2026-03-09 10:36:03 +08:00
return (
{
southerly : "南 / 西南风" ,
northerly : "北 / 西北风" ,
easterly : "东风" ,
westerly : "西风" ,
}[ bucket || "" ] || "风向不明"
);
}
2026-03-24 03:44:12 +08:00
function formatTafMarkerType ( type : string , locale : Locale = "zh-CN" ) {
const normalized = String ( type || "" ). trim (). toUpperCase ();
if ( isEnglish ( locale )) {
return (
{
BASE : "Base regime" ,
FM : "Hard shift" ,
TEMPO : "Temporary swing" ,
BECMG : "Gradual shift" ,
PROB30 : "30% risk window" ,
PROB40 : "40% risk window" ,
"PROB30 TEMPO" : "30% temporary swing" ,
"PROB40 TEMPO" : "40% temporary swing" ,
}[ normalized ] || normalized
);
}
return (
{
BASE : "基础时段" ,
FM : "明确切换" ,
TEMPO : "临时波动" ,
BECMG : "逐步转变" ,
PROB30 : "30% 风险窗" ,
PROB40 : "40% 风险窗" ,
"PROB30 TEMPO" : "30% 临时波动" ,
"PROB40 TEMPO" : "40% 临时波动" ,
}[ normalized ] || normalized
);
}
2026-03-10 04:45:40 +08:00
export function wuRound ( value : number | null | undefined ) {
const numeric = Number ( value );
if ( ! Number . isFinite ( numeric )) return null ;
return numeric >= 0
? Math . floor ( numeric + 0.5 )
: Math . ceil ( numeric - 0.5 );
}
2026-03-09 10:36:03 +08:00
export function formatDelta ( value : number | null | undefined , suffix = "" ) {
const numeric = Number ( value );
if ( ! Number . isFinite ( numeric )) return "--" ;
const sign = numeric > 0 ? "+" : "" ;
return ` ${ sign }${ numeric . toFixed ( 1 ) }${ suffix } ` ;
}
function getForecastTextForDate ( detail : CityDetail , dateStr : string ) {
const periods = detail . source_forecasts ? . weather_gov ? . forecast_periods || [];
return periods . filter (( period ) =>
String ( period . start_time || "" ). startsWith ( dateStr ),
);
}
2026-03-10 04:45:40 +08:00
export function computeFrontTrendSignal (
detail : CityDetail ,
dateStr : string ,
locale : Locale = "zh-CN" ,
) {
2026-03-24 01:28:45 +08:00
const upperAirSignal = detail . vertical_profile_signal || {};
2026-03-24 02:58:29 +08:00
const tafSignal = detail . taf ? . signal || {};
2026-03-24 02:07:15 +08:00
const upperAirTradeCue = upperAirSignal . source
? upperAirSignal . heating_setup === "supportive"
? {
label : isEnglish ( locale ) ? "Trade cue" : "交易动作" ,
note : isEnglish ( locale )
2026-03-24 19:41:29 +08:00
? "The setup still supports further warming. Do not call the high too early."
: "结构仍支持继续升温,别太早押高温见顶。" ,
2026-03-24 02:07:15 +08:00
tone : "warm" ,
value : isEnglish ( locale ) ? "Lean warmer" : "偏暖侧" ,
}
: upperAirSignal . heating_setup === "suppressed"
? {
label : isEnglish ( locale ) ? "Trade cue" : "交易动作" ,
note : isEnglish ( locale )
2026-03-24 19:41:29 +08:00
? "Further upside looks less reliable. Do not chase the high blindly."
: "高温继续上冲的把握不大,别盲目追热。" ,
2026-03-24 02:07:15 +08:00
tone : "cold" ,
value : isEnglish ( locale ) ? "Lean cautious" : "偏谨慎" ,
}
: {
label : isEnglish ( locale ) ? "Trade cue" : "交易动作" ,
note : isEnglish ( locale )
2026-03-24 19:41:29 +08:00
? "The picture is still mixed. Let the next move decide first."
: "现在还看不出明确方向,先等下一步走势确认。" ,
2026-03-24 02:07:15 +08:00
tone : "" ,
value : isEnglish ( locale ) ? "Wait / confirm" : "先观察" ,
}
: null ;
2026-03-24 02:58:29 +08:00
const baseUpperAirSummary = upperAirSignal . source
2026-03-24 01:49:00 +08:00
? (() => {
const hasMetrics =
upperAirSignal . cape_max != null ||
upperAirSignal . cin_min != null ||
upperAirSignal . boundary_layer_height_max != null ||
upperAirSignal . shear_10m_180m_max != null ;
if ( ! hasMetrics ) {
return isEnglish ( locale )
? "Upper-air inputs are incomplete. For now, trade direction should rely more on surface structure."
: "高空输入还不完整,当前交易方向先更多参考近地面结构信号。" ;
}
if ( upperAirSignal . heating_setup === "supportive" ) {
return isEnglish ( locale )
2026-03-24 19:41:29 +08:00
? "Upper-air structure still favors further warming. Do not call the high too early."
: "高空结构仍偏向继续增温,别太早押高温见顶。" ;
2026-03-24 01:49:00 +08:00
}
if ( upperAirSignal . heating_setup === "suppressed" ) {
return isEnglish ( locale )
2026-03-24 19:41:29 +08:00
? "Upper-air structure leans toward capping the afternoon high. Further upside looks less reliable."
: "高空结构更偏向压住午后峰值,高温继续上冲的把握不大。" ;
2026-03-24 01:49:00 +08:00
}
return isEnglish ( locale )
2026-03-24 19:41:29 +08:00
? "Upper-air structure is fairly neutral. It does not provide a clean edge yet."
: "高空结构整体偏中性,暂时还给不出明确方向。" ;
2026-03-24 01:49:00 +08:00
})()
: "" ;
2026-03-24 02:58:29 +08:00
const tafSummary =
tafSignal . available && dateStr === detail . local_date
? isEnglish ( locale )
? String ( tafSignal . summary_en || "" ). trim ()
: String ( tafSignal . summary_zh || "" ). trim ()
: "" ;
2026-03-24 16:32:43 +08:00
let upperAirSummary = baseUpperAirSummary ;
let tafMetric :
| {
label : string ;
note : string ;
tone? : string ;
value : string ;
}
| null = null ;
let upperAirMetrics : Array < {
label : string ;
note : string ;
tone? : string ;
value : string ;
} > = [];
2026-04-08 11:41:34 +08:00
const localizedCommentary =
2026-03-24 16:32:43 +08:00
dateStr === detail . local_date
2026-04-08 11:41:34 +08:00
? getLocalizedDynamicCommentary ( detail , locale )
: { headline : "" , bullets : [], source : "" };
2026-03-24 16:32:43 +08:00
const backendSummary =
2026-04-08 11:41:34 +08:00
localizedCommentary . headline &&
( ! isEnglish ( locale ) || ! containsCjk ( localizedCommentary . headline ))
? localizedCommentary . headline
2026-03-24 16:32:43 +08:00
: "" ;
2026-04-08 11:41:34 +08:00
const backendNotes = localizedCommentary . bullets . filter (
2026-03-24 16:32:43 +08:00
( note ) => ! isEnglish ( locale ) || ! containsCjk ( note ),
);
const slice = getFutureSlice ( detail , dateStr );
const currentTemp = Number ( detail . current ? . temp );
const currentDew = Number ( detail . current ? . dewpoint );
if ( ! slice . length ) {
2026-03-25 15:22:41 +08:00
const fallbackSummary =
backendSummary ||
( isEnglish ( locale )
? "Insufficient intraday structured data. Keep baseline monitoring."
: "当日日内结构化数据不足,暂时只保留基础监控。" );
2026-03-24 16:32:43 +08:00
return {
confidence : "low" ,
label : isEnglish ( locale ) ? "Monitoring" : "监控中" ,
metrics : [] as Array < {
label : string ;
note : string ;
tone? : string ;
value : string ;
} > ,
upperAirMetrics ,
upperAirSummary ,
precipMax : 0 ,
score : 0 ,
2026-03-25 15:22:41 +08:00
summary : fallbackSummary ,
summaryLines : [ fallbackSummary ],
2026-03-24 16:32:43 +08:00
weatherGovPeriods : [] as ReturnType < typeof getForecastTextForDate >,
};
}
const normalizeHm = ( value : unknown ) : 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" ) } ` ;
};
const hmToMinutes = ( value : string | null ) => {
if ( ! value ) return null ;
const [ hourPart , minutePart ] = value . split ( ":" );
const hour = Number . parseInt ( hourPart || "" , 10 );
const minute = Number . parseInt ( minutePart || "" , 10 );
if (
! Number . isFinite ( hour ) ||
! Number . isFinite ( minute ) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return null ;
}
return hour * 60 + minute ;
};
const pointMinutes = ( point : { label? : string }) =>
hmToMinutes ( normalizeHm ( point . label ));
const isTargetToday = dateStr === detail . local_date ;
const currentHm = normalizeHm ( detail . local_time );
const sunsetHm = normalizeHm ( detail . forecast ? . sunset );
const peakFirstHour = Number ( detail . peak ? . first_h );
const peakLastHour = Number ( detail . peak ? . last_h );
const hasPeakWindow =
Number . isFinite ( peakFirstHour ) &&
Number . isFinite ( peakLastHour ) &&
peakFirstHour >= 0 &&
peakLastHour >= peakFirstHour ;
const currentMinutes = hmToMinutes ( currentHm );
const sunsetMinutes = hmToMinutes ( sunsetHm );
const peakWindowStartMinutes = hasPeakWindow
? Math . max ( 0 , ( peakFirstHour - 2 ) * 60 )
: null ;
const peakWindowEndMinutes = hasPeakWindow
? Math . min ( 23 * 60 + 59 , ( peakLastHour + 1 ) * 60 )
: null ;
const canUseSunsetWindow =
isTargetToday &&
currentMinutes !== null &&
sunsetMinutes !== null &&
sunsetMinutes > currentMinutes ;
const tafMarkers = Array . isArray ( tafSignal . markers ) ? tafSignal . markers : [];
const markerSummary = (
marker :
| {
2026-03-25 15:22:41 +08:00
marker_type? : string | null ;
start_local? : string | null ;
end_local? : string | null ;
suppression_level? : string | null ;
2026-03-24 16:32:43 +08:00
summary_zh? : string | null ;
summary_en? : string | null ;
}
| null
| undefined ,
) =>
! marker
? ""
: isEnglish ( locale )
? String ( marker . summary_en || "" ). trim ()
: String ( marker . summary_zh || "" ). trim ();
const currentTafMarker =
tafSignal . available && currentMinutes !== null
? tafMarkers . find (( marker ) => {
const start = hmToMinutes ( normalizeHm ( marker ? . start_local ));
const end = hmToMinutes ( normalizeHm ( marker ? . end_local ));
if ( start === null || end === null ) return false ;
return currentMinutes >= start && currentMinutes <= end ;
})
: null ;
const nextTafMarker =
tafSignal . available && currentMinutes !== null && ! currentTafMarker
? tafMarkers . find (( marker ) => {
const start = hmToMinutes ( normalizeHm ( marker ? . start_local ));
return start !== null && start > currentMinutes ;
})
: null ;
2026-03-25 15:22:41 +08:00
const sameMarker = (
left :
| { marker_type? : string | null ; start_local? : string | null ; end_local? : string | null }
| null
| undefined ,
right :
| { marker_type? : string | null ; start_local? : string | null ; end_local? : string | null }
| null
| undefined ,
) =>
!! left &&
!! right &&
String ( left . marker_type || "" ) === String ( right . marker_type || "" ) &&
String ( left . start_local || "" ) === String ( right . start_local || "" ) &&
String ( left . end_local || "" ) === String ( right . end_local || "" );
2026-03-25 21:46:33 +08:00
const formatTafSegmentBody = (
2026-03-25 15:22:41 +08:00
marker :
| {
marker_type? : string | null ;
start_local? : string | null ;
end_local? : string | null ;
suppression_level? : string | null ;
}
| null
| undefined ,
kind : "current" | "next" | "peak" ,
) => {
if ( ! marker ) return "" ;
const range = ` ${ normalizeHm ( marker . start_local ) || "--:--" } - ${ normalizeHm ( marker . end_local ) || "--:--" } ` ;
const typeLabel = formatTafMarkerType (
String ( marker . marker_type || "" ),
locale ,
);
const level = String ( marker . suppression_level || "low" ). toLowerCase ();
const statusText = isEnglish ( locale )
? level === "high"
? "shows shower or thunderstorm disruption"
: level === "medium"
? "shows cloud or light-rain disruption"
: "stays relatively stable"
: level === "high"
? "有阵雨或雷暴扰动"
: level === "medium"
? "有云量或弱降水扰动"
: "以稳定为主" ;
return isEnglish ( locale )
2026-03-25 21:46:33 +08:00
? ` ${ typeLabel } ( ${ range } ), ${ statusText } .`
: ` ${ typeLabel } ( ${ range } ), ${ statusText } 。` ;
2026-03-25 15:22:41 +08:00
};
2026-03-24 16:32:43 +08:00
const peakWindowTafMarker =
tafSignal . available &&
peakWindowStartMinutes !== null &&
peakWindowEndMinutes !== null
? tafMarkers . find (( marker ) => {
const start = hmToMinutes ( normalizeHm ( marker ? . start_local ));
const end = hmToMinutes ( normalizeHm ( marker ? . end_local ));
if ( start === null || end === null ) return false ;
return start <= peakWindowEndMinutes && end >= peakWindowStartMinutes ;
})
: null ;
const peakTafSummary = markerSummary ( peakWindowTafMarker );
2026-03-24 19:21:28 +08:00
const peakTafStillRelevant =
peakWindowEndMinutes === null ||
currentMinutes === null ||
currentMinutes < peakWindowEndMinutes ;
2026-03-24 16:32:43 +08:00
const effectivePeakTafSummary =
2026-03-24 19:21:28 +08:00
peakTafStillRelevant &&
peakTafSummary &&
2026-03-25 15:22:41 +08:00
! sameMarker ( peakWindowTafMarker , currentTafMarker ) &&
! sameMarker ( peakWindowTafMarker , nextTafMarker )
2026-03-24 19:21:28 +08:00
? peakTafSummary
: "" ;
2026-03-25 15:22:41 +08:00
const currentTafLine = currentTafMarker
2026-03-25 21:46:33 +08:00
? isEnglish ( locale )
? `Use current TAF as primary: ${ formatTafSegmentBody ( currentTafMarker , "current" ) } `
: `以当前 TAF 为准: ${ formatTafSegmentBody ( currentTafMarker , "current" ) } `
2026-03-25 15:22:41 +08:00
: nextTafMarker
2026-03-25 21:46:33 +08:00
? isEnglish ( locale )
? `Use next TAF segment as primary: ${ formatTafSegmentBody ( nextTafMarker , "next" ) } `
: `以下一段 TAF 为准: ${ formatTafSegmentBody ( nextTafMarker , "next" ) } `
2026-03-25 15:22:41 +08:00
: "" ;
2026-03-25 21:46:33 +08:00
const currentTafLineForSummary = currentTafLine . replace ( /^Use (current|next) TAF (as primary|segment as primary):\s*/i , "" ). replace ( /^以(当前|下一段) TAF 为准:/ , "" );
2026-03-25 15:22:41 +08:00
const peakTafLine =
effectivePeakTafSummary && peakWindowTafMarker
2026-03-25 21:46:33 +08:00
? isEnglish ( locale )
? `Peak-window reference: ${ formatTafSegmentBody ( peakWindowTafMarker , "peak" ) } `
: `峰值窗口参考: ${ formatTafSegmentBody ( peakWindowTafMarker , "peak" ) } `
2026-03-25 15:22:41 +08:00
: "" ;
2026-03-25 21:46:33 +08:00
const peakTafLineForSummary = peakTafLine . replace ( /^Peak-window reference:\s*/i , "" ). replace ( /^峰值窗口参考:/ , "" );
2026-03-24 19:21:28 +08:00
const tafFallbackSummary =
currentTafLine || peakTafLine ? "" : tafSummary ;
2026-03-25 21:46:33 +08:00
const tafPrimarySummary = currentTafMarker
? isEnglish ( locale )
? `Use current TAF as primary: ${ currentTafLineForSummary } `
: `以当前 TAF 为准: ${ currentTafLineForSummary } `
: nextTafMarker
? isEnglish ( locale )
? `Use next TAF segment as primary: ${ currentTafLineForSummary } `
: `以下一段 TAF 为准: ${ currentTafLineForSummary } `
: "" ;
const tafReferenceSummary =
peakTafLineForSummary &&
peakTafLineForSummary !== currentTafLineForSummary
? isEnglish ( locale )
? `Peak-window reference: ${ peakTafLineForSummary } `
: `峰值窗口参考: ${ peakTafLineForSummary } `
: "" ;
2026-03-24 16:32:43 +08:00
upperAirSummary = [
baseUpperAirSummary ,
2026-03-25 21:46:33 +08:00
tafPrimarySummary ,
tafReferenceSummary ,
2026-03-24 19:21:28 +08:00
tafFallbackSummary ,
2026-03-24 16:32:43 +08:00
]
2026-03-24 02:58:29 +08:00
. filter ( Boolean )
. join ( isEnglish ( locale ) ? " " : "" );
2026-03-24 16:32:43 +08:00
tafMetric =
2026-03-24 02:58:29 +08:00
tafSignal . available && dateStr === detail . local_date
? {
label : isEnglish ( locale ) ? "Airport TAF" : "机场预报" ,
2026-03-24 16:32:43 +08:00
note :
2026-03-25 21:46:33 +08:00
tafPrimarySummary ||
tafReferenceSummary ||
2026-03-24 19:21:28 +08:00
tafFallbackSummary ||
2026-03-24 02:58:29 +08:00
( isEnglish ( locale )
? "Airport TAF is available for the current peak window."
: "当前峰值窗口已接入机场 TAF 预报。" ),
tone :
tafSignal.suppression_level === "high"
? "cold"
: tafSignal . suppression_level === "low"
? "warm"
: "" ,
value :
tafSignal.suppression_level === "high"
? isEnglish ( locale )
? "Suppression watch"
: "防压温"
: tafSignal . suppression_level === "medium"
? isEnglish ( locale )
? "Watch clouds/rain"
: "看云雨"
: isEnglish ( locale )
? "Mostly stable"
: "暂稳" ,
}
: null ;
2026-03-24 16:32:43 +08:00
upperAirMetrics = upperAirSignal . source
2026-03-24 01:28:45 +08:00
? [
2026-03-24 02:07:15 +08:00
...( upperAirTradeCue ? [ upperAirTradeCue ] : []),
2026-03-24 01:28:45 +08:00
{
2026-03-24 01:49:00 +08:00
label : isEnglish ( locale ) ? "Peak setup" : "冲高环境" ,
note :
upperAirSignal.heating_setup === "supportive"
? isEnglish ( locale )
2026-03-24 19:41:29 +08:00
? "Still supportive of more daytime heating. Do not call the high too early."
: "结构仍支持白天继续升温,别太早押高温见顶。"
2026-03-24 01:49:00 +08:00
: upperAirSignal . heating_setup === "suppressed"
? isEnglish ( locale )
2026-03-24 19:41:29 +08:00
? "Leans toward capping the afternoon peak. Further upside looks less reliable."
: "更偏向压住午后峰值,高温继续上冲的把握不大。"
2026-03-24 01:49:00 +08:00
: isEnglish ( locale )
2026-03-24 19:41:29 +08:00
? "Neutral on its own. It does not provide a clear directional edge yet."
: "单看这层偏中性,暂时还没有明确方向优势。" ,
2026-03-24 01:49:00 +08:00
tone :
upperAirSignal.heating_setup === "supportive"
? "warm"
: upperAirSignal . heating_setup === "suppressed"
? "cold"
: "" ,
value :
upperAirSignal.heating_setup === "supportive"
? isEnglish ( locale )
? "Supportive"
: "偏支持"
: upperAirSignal . heating_setup === "suppressed"
? isEnglish ( locale )
? "Suppressed"
: "偏压制"
: isEnglish ( locale )
? "Neutral"
: "中性" ,
},
{
label : isEnglish ( locale ) ? "Peak suppression risk" : "压温风险" ,
2026-03-24 01:28:45 +08:00
note :
upperAirSignal.cape_max != null || upperAirSignal . cin_min != null
? isEnglish ( locale )
2026-03-24 01:49:00 +08:00
? `How likely clouds or showers are to cap the high. CAPE ${ Math . round ( Number ( upperAirSignal . cape_max ?? 0 )) } , CIN ${ Number ( upperAirSignal . cin_min ?? 0 ). toFixed ( 0 ) } .`
: `看云和阵雨有多大概率把峰值压住。CAPE ${ Math . round ( Number ( upperAirSignal . cape_max ?? 0 )) } , CIN ${ Number ( upperAirSignal . cin_min ?? 0 ). toFixed ( 0 ) } 。`
2026-03-24 01:28:45 +08:00
: isEnglish ( locale )
2026-03-24 01:49:00 +08:00
? "Estimated from the next 48h upper-air profile."
2026-03-24 01:28:45 +08:00
: "根据未来 48 小时高空剖面估算。" ,
tone :
upperAirSignal.suppression_risk === "high"
? "cold"
: upperAirSignal . suppression_risk === "low"
? "warm"
: "" ,
value :
upperAirSignal.suppression_risk === "high"
? isEnglish ( locale )
? "High"
: "高"
: upperAirSignal . suppression_risk === "medium"
? isEnglish ( locale )
? "Medium"
: "中"
: isEnglish ( locale )
? "Low"
: "低" ,
},
{
2026-03-24 01:49:00 +08:00
label : isEnglish ( locale ) ? "Afternoon disruption" : "午后扰动" ,
2026-03-24 01:28:45 +08:00
note :
upperAirSignal.lifted_index_min != null
? isEnglish ( locale )
2026-03-24 01:49:00 +08:00
? `How easily the afternoon can turn noisy. Lifted Index ${ Number ( upperAirSignal . lifted_index_min ). toFixed ( 1 ) } .`
: `看午后是否容易突然起云、起对流,把走势搅乱。Lifted Index ${ Number ( upperAirSignal . lifted_index_min ). toFixed ( 1 ) } 。`
2026-03-24 01:28:45 +08:00
: isEnglish ( locale )
? "Uses instability and lifted-index structure."
: "结合不稳定能量与抬升指数判断。" ,
tone :
upperAirSignal.trigger_risk === "high"
? "cold"
: upperAirSignal . trigger_risk === "low"
? "warm"
: "" ,
value :
upperAirSignal.trigger_risk === "high"
? isEnglish ( locale )
? "High"
: "高"
: upperAirSignal . trigger_risk === "medium"
? isEnglish ( locale )
? "Medium"
: "中"
: isEnglish ( locale )
? "Low"
: "低" ,
},
{
2026-03-24 01:49:00 +08:00
label : isEnglish ( locale ) ? "Heating efficiency" : "冲高效率" ,
2026-03-24 01:28:45 +08:00
note :
upperAirSignal.boundary_layer_height_max != null
? isEnglish ( locale )
2026-03-24 01:49:00 +08:00
? `How efficiently surface warmth can keep translating upward. Mixing depth peaks near ${ Math . round ( Number ( upperAirSignal . boundary_layer_height_max )) } m.`
: `看地面热量能不能持续往上送,决定冲高效率。混合层高度峰值约 ${ Math . round ( Number ( upperAirSignal . boundary_layer_height_max )) } 米。`
2026-03-24 01:28:45 +08:00
: isEnglish ( locale )
2026-03-24 01:49:00 +08:00
? "Tracks daytime mixing depth."
: "跟踪白天混合层深度。" ,
2026-03-24 01:28:45 +08:00
tone :
upperAirSignal.mixing_strength === "strong"
? "warm"
: upperAirSignal . mixing_strength === "weak"
? "cold"
: "" ,
value :
upperAirSignal.mixing_strength === "strong"
? isEnglish ( locale )
? "Strong"
: "强"
: upperAirSignal . mixing_strength === "medium"
? isEnglish ( locale )
? "Medium"
: "中"
: isEnglish ( locale )
2026-03-24 16:32:43 +08:00
? "Weak"
: "弱" ,
2026-03-24 01:28:45 +08:00
},
2026-03-24 02:58:29 +08:00
...( tafMetric ? [ tafMetric ] : []),
2026-03-24 01:28:45 +08:00
]
2026-03-24 02:58:29 +08:00
: tafMetric
? [ tafMetric ]
: [];
2026-03-16 10:08:48 +08:00
const futureSlice =
isTargetToday && currentMinutes !== null
? slice . filter (( point ) => {
const minutes = pointMinutes ( point );
return minutes === null ? true : minutes >= currentMinutes ;
})
: slice ;
const untilSunsetSlice =
canUseSunsetWindow && sunsetMinutes !== null
? futureSlice . filter (( point ) => {
const minutes = pointMinutes ( point );
return minutes === null ? false : minutes <= sunsetMinutes ;
})
: futureSlice ;
2026-03-24 02:07:15 +08:00
const aroundPeakSlice =
isTargetToday &&
peakWindowStartMinutes !== null &&
peakWindowEndMinutes !== null
? futureSlice . filter (( point ) => {
const minutes = pointMinutes ( point );
return minutes === null
? false
: minutes >= peakWindowStartMinutes && minutes <= peakWindowEndMinutes ;
})
: [];
2026-03-16 10:08:48 +08:00
const workingSlice =
2026-03-24 02:07:15 +08:00
aroundPeakSlice . length >= 2
? aroundPeakSlice
: untilSunsetSlice.length >= 2
2026-03-16 10:08:48 +08:00
? untilSunsetSlice
: futureSlice.length >= 2
? futureSlice
: slice ;
2026-03-24 02:07:15 +08:00
const usingPeakWindow = aroundPeakSlice . length >= 2 ;
2026-03-16 10:08:48 +08:00
const usingSunsetWindow = canUseSunsetWindow && untilSunsetSlice . length >= 2 ;
const first = workingSlice [ 0 ] || slice [ 0 ];
const last = workingSlice [ workingSlice . length - 1 ] || slice [ slice . length - 1 ];
const effectiveHours = Math . max ( 1 , workingSlice . length );
const windowLabel = ` ${ first ? . label || "--" } - ${ last ? . label || "--" } ` ;
const windowText = isEnglish ( locale )
2026-03-24 02:07:15 +08:00
? usingPeakWindow
? `today ${ windowLabel } (~ ${ effectiveHours } h, around peak window)`
: usingSunsetWindow
2026-03-16 10:08:48 +08:00
? `today ${ windowLabel } (~ ${ effectiveHours } h, now -> sunset)`
: isTargetToday
? `today ${ windowLabel } (~ ${ effectiveHours } h)`
: `daily ${ windowLabel } (~ ${ effectiveHours } h)`
2026-03-24 02:07:15 +08:00
: usingPeakWindow
? `今日 ${ windowLabel } (约 ${ effectiveHours } 小时,围绕峰值窗口)`
: usingSunsetWindow
2026-03-16 10:08:48 +08:00
? `今日 ${ windowLabel } (约 ${ effectiveHours } 小时,当前至日落)`
: isTargetToday
? `今日 ${ windowLabel } (约 ${ effectiveHours } 小时)`
: `当日日内 ${ windowLabel } (约 ${ effectiveHours } 小时)` ;
2026-03-09 10:36:03 +08:00
const firstTemp = Number . isFinite ( Number ( first . temp )) ? Number ( first . temp ) : currentTemp ;
const lastTemp = Number . isFinite ( Number ( last . temp )) ? Number ( last . temp ) : firstTemp ;
const tempDelta =
Number . isFinite ( firstTemp ) && Number . isFinite ( lastTemp ) ? lastTemp - firstTemp : 0 ;
const firstDew = Number . isFinite ( Number ( first . dewPoint ))
? Number ( first . dewPoint )
: currentDew ;
const lastDew = Number . isFinite ( Number ( last . dewPoint ))
? Number ( last . dewPoint )
: firstDew ;
const dewDelta =
Number . isFinite ( firstDew ) && Number . isFinite ( lastDew ) ? lastDew - firstDew : 0 ;
const firstPressure = Number . isFinite ( Number ( first . pressure ))
? Number ( first . pressure )
: null ;
const lastPressure = Number . isFinite ( Number ( last . pressure ))
? Number ( last . pressure )
: firstPressure ;
const pressureDelta =
Number . isFinite ( Number ( firstPressure )) && Number . isFinite ( Number ( lastPressure ))
? Number ( lastPressure ) - Number ( firstPressure )
: 0 ;
const firstCloud = Number . isFinite ( Number ( first . cloudCover ))
? Number ( first . cloudCover )
: null ;
const lastCloud = Number . isFinite ( Number ( last . cloudCover ))
? Number ( last . cloudCover )
: firstCloud ;
const cloudDelta =
Number . isFinite ( Number ( firstCloud )) && Number . isFinite ( Number ( lastCloud ))
? Number ( lastCloud ) - Number ( firstCloud )
: 0 ;
const precipMax = slice . reduce (
( max , point ) => Math . max ( max , Number ( point . precipProb ) || 0 ),
0 ,
);
2026-04-11 10:16:00 +08:00
const precipWindowSource =
futureSlice . length >= 2 ? futureSlice : slice ;
const precipCandidates = precipWindowSource
. map (( point ) => ({
label : normalizeHm ( point . label ),
minute : pointMinutes ( point ),
precip : Number ( point . precipProb ) || 0 ,
}))
. filter (
( point ) : point is { label : string ; minute : number ; precip : number } =>
point . label != null && point . minute != null ,
);
const precipThreshold = precipMax >= 70 ? 50 : precipMax >= 40 ? 40 : 0 ;
const precipWindows : Array < {
start : number ;
end : number ;
startLabel : string ;
endLabel : string ;
peak : number ;
} > = [];
if ( precipThreshold > 0 ) {
let active :
| {
start : number ;
end : number ;
startLabel : string ;
endLabel : string ;
peak : number ;
}
| null = null ;
for ( const point of precipCandidates ) {
if ( point . precip >= precipThreshold ) {
if ( ! active ) {
active = {
start : point.minute ,
end : point.minute ,
startLabel : point.label ,
endLabel : point.label ,
peak : point.precip ,
};
} else {
active . end = point . minute ;
active . endLabel = point . label ;
active . peak = Math . max ( active . peak , point . precip );
}
} else if ( active ) {
precipWindows . push ( active );
active = null ;
}
}
if ( active ) precipWindows . push ( active );
}
const primaryPrecipWindow =
precipWindows . length > 0
? [... precipWindows ]. sort (( left , right ) => {
if ( right . peak !== left . peak ) return right . peak - left . peak ;
return ( right . end - right . start ) - ( left . end - left . start );
})[ 0 ]
: null ;
const precipWindowLabel = primaryPrecipWindow
? ` ${ primaryPrecipWindow . startLabel } - ${ primaryPrecipWindow . endLabel } `
: "" ;
const precipPeakOverlap =
primaryPrecipWindow &&
peakWindowStartMinutes != null &&
peakWindowEndMinutes != null
? Math . max (
0 ,
Math . min ( primaryPrecipWindow . end , peakWindowEndMinutes ) -
Math . max ( primaryPrecipWindow . start , peakWindowStartMinutes ),
)
: 0 ;
const precipOverlapLevel =
primaryPrecipWindow && peakWindowStartMinutes != null && peakWindowEndMinutes != null
? precipPeakOverlap >= 120
? "high"
: precipPeakOverlap > 0
? "medium"
: "low"
: "unknown" ;
const precipWindowSummary = (() => {
if ( ! primaryPrecipWindow ) {
return isEnglish ( locale )
? "No concentrated model precipitation window is visible yet."
: "模型里暂时还看不出明显集中的降水窗口。" ;
}
const overlapText = isEnglish ( locale )
? precipOverlapLevel === "high"
? "It overlaps heavily with the peak window."
: precipOverlapLevel === "medium"
? "It overlaps part of the peak window."
: "It sits mostly outside the peak window."
: precipOverlapLevel === "high"
? "与峰值窗口重叠较高。"
: precipOverlapLevel === "medium"
? "与峰值窗口部分重叠。"
: "主要落在峰值窗口之外。" ;
return isEnglish ( locale )
? `Model precipitation window is ${ precipWindowLabel } , peaking near ${ Math . round ( primaryPrecipWindow . peak ) } %. ${ overlapText } `
: `模型降水窗口在 ${ precipWindowLabel } ,峰值约 ${ Math . round ( primaryPrecipWindow . peak ) } %。 ${ overlapText } ` ;
})();
2026-03-09 10:36:03 +08:00
const firstBucket = trendBucketFromDir ( first . windDir );
const lastBucket = trendBucketFromDir ( last . windDir );
const weatherGovPeriods = getForecastTextForDate ( detail , dateStr );
const weatherGovText = weatherGovPeriods
. map (
( period ) =>
` ${ period . short_forecast || "" } ${ period . detailed_forecast || "" } ` . toLowerCase (),
)
. join ( " " );
let warmScore = 0 ;
let coldScore = 0 ;
if ( tempDelta >= 2 ) warmScore += 24 ;
else if ( tempDelta >= 0.8 ) warmScore += 12 ;
if ( tempDelta <= - 2 ) coldScore += 24 ;
else if ( tempDelta <= - 0.8 ) coldScore += 12 ;
if ( dewDelta >= 1.2 ) warmScore += 14 ;
if ( dewDelta <= - 1.2 ) coldScore += 10 ;
if ( pressureDelta >= 1.2 ) coldScore += 16 ;
if ( pressureDelta <= - 1.0 ) warmScore += 8 ;
if ( lastBucket === "southerly" ) warmScore += 14 ;
if ( firstBucket !== lastBucket && lastBucket === "southerly" ) warmScore += 10 ;
if ( lastBucket === "northerly" ) coldScore += 14 ;
if ( firstBucket !== lastBucket && lastBucket === "northerly" ) coldScore += 10 ;
if ( cloudDelta >= 15 && tempDelta >= 0 ) warmScore += 6 ;
if ( cloudDelta >= 15 && tempDelta < 0 ) coldScore += 8 ;
if ( precipMax >= 40 ) coldScore += 8 ;
if (
weatherGovText . includes ( "cold front" ) ||
weatherGovText . includes ( "temperatures falling" )
) {
coldScore += 18 ;
}
if ( weatherGovText . includes ( "warm front" ) || weatherGovText . includes ( "warmer" )) {
warmScore += 18 ;
}
if ( weatherGovText . includes ( "thunder" ) || weatherGovText . includes ( "snow" )) {
coldScore += 8 ;
}
const score = Math . max ( - 100 , Math . min ( 100 , warmScore - coldScore ));
2026-03-22 22:26:50 +08:00
const warmLabel = isEnglish ( locale ) ? "Near-term warming bias" : "未来偏升温" ;
const coldLabel = isEnglish ( locale ) ? "Near-term cooling bias" : "未来偏降温" ;
const monitorLabel = isEnglish ( locale ) ? "Direction unclear" : "方向不清" ;
2026-03-10 04:45:40 +08:00
const label = score >= 18 ? warmLabel : score <= - 18 ? coldLabel : monitorLabel ;
2026-03-09 10:36:03 +08:00
const confidence =
Math . abs ( score ) >= 45 ? "high" : Math . abs ( score ) >= 22 ? "medium" : "low" ;
2026-03-22 22:26:50 +08:00
const directionalLead = (() => {
if ( isEnglish ( locale )) {
if ( score >= 18 && tempDelta >= 0.5 ) {
return `Over ${ windowText } , temperatures still lean warmer.` ;
}
if ( score <= - 18 && tempDelta <= - 0.5 ) {
return `Over ${ windowText } , temperatures still lean cooler.` ;
}
if ( score >= 18 ) {
return `Over ${ windowText } , the structure still leans warmer, but the warming pace is not strong yet.` ;
}
if ( score <= - 18 ) {
return `Over ${ windowText } , the structure still leans cooler, but the cooling pace is not decisive yet.` ;
}
if ( tempDelta >= 0.8 ) {
return `Over ${ windowText } , temperatures still lean warmer, but confidence is limited.` ;
}
if ( tempDelta <= - 0.8 ) {
return `Over ${ windowText } , temperatures still lean cooler, but confidence is limited.` ;
}
return `Over ${ windowText } , temperatures are more likely to stay range-bound for now.` ;
}
if ( score >= 18 && tempDelta >= 0.5 ) {
return ` ${ windowText } 偏增温,后续更可能继续往上走。` ;
}
if ( score <= - 18 && tempDelta <= - 0.5 ) {
return ` ${ windowText } 偏降温,后续更可能继续往下走。` ;
}
if ( score >= 18 ) {
return ` ${ windowText } 仍偏增温,但增温兑现力度暂时不算强。` ;
}
if ( score <= - 18 ) {
return ` ${ windowText } 仍偏降温,但降温兑现力度暂时不算强。` ;
}
if ( tempDelta >= 0.8 ) {
return ` ${ windowText } 略偏增温,但结构信号置信度有限。` ;
}
if ( tempDelta <= - 0.8 ) {
return ` ${ windowText } 略偏降温,但结构信号置信度有限。` ;
}
return ` ${ windowText } 更像震荡整理,短时升降温方向暂不清晰。` ;
})();
2026-03-21 18:54:23 +08:00
const summary = (() => {
const parts : string [] = [];
if ( isEnglish ( locale )) {
2026-03-22 22:26:50 +08:00
parts . push ( directionalLead );
2026-03-21 18:54:23 +08:00
if ( lastBucket === "southerly" && firstBucket !== "southerly" ) {
parts . push ( "Low-level wind turns more southerly." );
} else if ( lastBucket === "northerly" && firstBucket !== "northerly" ) {
parts . push ( "Low-level wind shifts toward a northerly regime." );
}
if ( tempDelta >= 0.8 ) {
parts . push ( `Temperature rises by ${ formatDelta ( tempDelta , detail . temp_symbol ) } .` );
} else if ( tempDelta <= - 0.8 ) {
parts . push ( `Temperature eases by ${ formatDelta ( tempDelta , detail . temp_symbol ) } .` );
}
if ( dewDelta >= 0.8 ) {
parts . push ( "Dew point is lifting, suggesting moisture transport is strengthening." );
} else if ( dewDelta <= - 0.8 ) {
parts . push ( "Dew point is falling, so low-level air is turning drier." );
}
if ( cloudDelta >= 15 ) {
parts . push ( "Cloud cover is building." );
} else if ( cloudDelta <= - 15 ) {
parts . push ( "Cloud cover is easing." );
}
if ( pressureDelta >= 1 ) {
parts . push ( "Pressure rebound argues for a cooler push." );
} else if ( pressureDelta <= - 1 ) {
parts . push ( "Pressure is softening, which is less hostile to warming." );
}
if ( precipMax >= 50 ) {
2026-04-11 10:16:00 +08:00
parts . push (
primaryPrecipWindow
? `Model precipitation risk concentrates in ${ precipWindowLabel } .`
: "Precipitation risk is high enough to watch for cloud/rain suppression." ,
);
2026-03-21 18:54:23 +08:00
}
if ( ! parts . length ) {
parts . push ( `Structured trend is mixed, so the core judgement still centers on ${ windowText } .` );
} else {
parts . push ( `Core judgement remains focused on ${ windowText } .` );
}
} else {
2026-03-22 22:26:50 +08:00
parts . push ( directionalLead );
2026-03-21 18:54:23 +08:00
if ( lastBucket === "southerly" && firstBucket !== "southerly" ) {
parts . push ( "低层风向更偏南,暖空气输送权重上升。" );
} else if ( lastBucket === "northerly" && firstBucket !== "northerly" ) {
parts . push ( "低层风向转偏北,冷空气影响权重上升。" );
}
if ( tempDelta >= 0.8 ) {
parts . push ( `气温抬升 ${ formatDelta ( tempDelta , detail . temp_symbol ) } 。` );
} else if ( tempDelta <= - 0.8 ) {
parts . push ( `气温回落 ${ formatDelta ( tempDelta , detail . temp_symbol ) } 。` );
}
if ( dewDelta >= 0.8 ) {
parts . push ( "露点同步上升,说明暖湿输送在增强。" );
} else if ( dewDelta <= - 0.8 ) {
parts . push ( "露点回落,低层空气在转干。" );
}
if ( cloudDelta >= 15 ) {
parts . push ( "云量正在增多。" );
} else if ( cloudDelta <= - 15 ) {
parts . push ( "云量正在回落。" );
}
if ( pressureDelta >= 1 ) {
parts . push ( "气压回升,更偏向冷空气压入。" );
} else if ( pressureDelta <= - 1 ) {
parts . push ( "气压走低,对增温压制减弱。" );
}
if ( precipMax >= 50 ) {
2026-04-11 10:16:00 +08:00
parts . push (
primaryPrecipWindow
? `模型降水窗口主要落在 ${ precipWindowLabel } 。`
: "降水概率已足以关注云雨压温。" ,
);
2026-03-21 18:54:23 +08:00
}
if ( ! parts . length ) {
parts . push ( `结构信号分化较大,核心仍围绕 ${ windowText } 观察。` );
} else {
parts . push ( `核心判断窗口仍以 ${ windowText } 为主。` );
}
}
return parts . join ( isEnglish ( locale ) ? " " : "" );
})();
2026-03-24 03:35:34 +08:00
const tafContrastSummary =
tafSignal . available && dateStr === detail . local_date
? (() => {
const tafSuppression = String (
tafSignal . suppression_level || "low" ,
). toLowerCase ();
const isCoolingBias = score <= - 18 ;
const isWarmingBias = score >= 18 ;
if ( tafSuppression === "low" && isCoolingBias ) {
return isEnglish ( locale )
? "TAF is not adding a new cloud/rain suppression signal, but the near-surface window is already leaning cooler, so the current cooling bias still comes mainly from surface structure."
: "TAF 没有新增云雨压温利空,但当前峰值窗口里的近地面结构已经偏弱,所以这次偏降温判断仍主要来自近地面信号。" ;
}
if ( tafSuppression === "low" && isWarmingBias ) {
return isEnglish ( locale )
? "TAF is not adding a new cloud/rain cap, and the warmer bias still comes mainly from the surface window."
: "TAF 没有新增云雨压温约束,当前偏升温判断仍主要来自近地面窗口。" ;
}
if ( tafSuppression === "medium" && isCoolingBias ) {
return isEnglish ( locale )
? "TAF is not the only driver here; it only reinforces part of the cooling-side case, while the main tilt still comes from the surface window."
: "这次偏降温不只是 TAF 在起作用;TAF 只是加强了部分冷侧判断,主方向仍来自近地面窗口。" ;
}
return "" ;
})()
: "" ;
2026-03-25 15:22:41 +08:00
const tafSummaryLineBody = [
2026-03-25 21:46:33 +08:00
tafPrimarySummary ,
tafReferenceSummary ,
2026-03-24 19:21:28 +08:00
tafFallbackSummary ,
2026-03-24 16:32:43 +08:00
tafContrastSummary ,
]
2026-03-24 02:58:29 +08:00
. filter ( Boolean )
. join ( isEnglish ( locale ) ? " " : "" );
2026-03-25 15:22:41 +08:00
const surfaceSummaryLine = summary
? isEnglish ( locale )
? `Surface: ${ summary } `
: `近地面: ${ summary } `
: "" ;
const tafSummaryLine = tafSummaryLineBody
? isEnglish ( locale )
? `Airport TAF: ${ tafSummaryLineBody } `
: `机场 TAF: ${ tafSummaryLineBody } `
: "" ;
const summaryLines = [ surfaceSummaryLine , tafSummaryLine ]. filter ( Boolean );
const combinedSummary = summaryLines . join ( isEnglish ( locale ) ? " " : "" );
2026-03-21 18:54:23 +08:00
const cloudNote = (() => {
if ( cloudDelta >= 15 && tempDelta >= 0.8 && dewDelta >= 0.8 ) {
return isEnglish ( locale )
? "Clouds are increasing while temperature and dew point still rise; this usually fits ongoing warm-moist transport rather than immediate cooling."
: "云量上升时温度和露点仍在抬升,更像暖湿输送持续中,而不是立刻转凉。" ;
}
if ( cloudDelta >= 15 && tempDelta >= 0 && lastBucket === "southerly" ) {
return isEnglish ( locale )
? "Clouds are building without clear cooling, and the low-level wind still leans southerly; watch for warm advection to continue."
: "云量增多但未明显降温,且低层风仍偏南,需继续关注暖平流是否延续。" ;
}
if ( cloudDelta >= 15 && tempDelta < 0 && precipMax >= 40 ) {
return isEnglish ( locale )
? "Clouds are thickening while temperature eases and precipitation risk is elevated; cloud/rain suppression is becoming more likely."
: "云量增厚且气温回落,同时降水概率偏高,更像云雨压温开始生效。" ;
}
if ( cloudDelta >= 15 && tempDelta < 0 && pressureDelta >= 1 ) {
return isEnglish ( locale )
? "Clouds are increasing while temperature softens and pressure rebounds; watch for cold-air push or frontal suppression."
: "云量上升同时气温走弱、气压回升,需留意冷空气压入或锋面压温。" ;
}
if ( cloudDelta <= - 15 && tempDelta >= 0.8 ) {
return isEnglish ( locale )
? "Cloud cover is easing while temperature rises; daytime heating efficiency is improving."
: "云量回落且温度抬升,白天增温效率在改善。" ;
}
return isEnglish ( locale )
2026-04-07 10:17:17 +08:00
? "Read forecast cloud-cover increase together with temperature, dew point, wind, and precipitation; it does not override the current observed sky condition."
: "这里显示的是预测窗口内的云量增幅,需要结合温度、露点、风向和降水一起看,不能覆盖当前实况的天空状况。" ;
2026-03-21 18:54:23 +08:00
})();
const dewNote = (() => {
if ( dewDelta >= 1.2 && tempDelta >= 0.8 ) {
return isEnglish ( locale )
? "Dew point and temperature rise together, which usually supports strengthening warm-moist transport."
: "露点和温度同步抬升,更偏向暖湿输送增强。" ;
}
if ( dewDelta >= 1.2 && precipMax >= 40 ) {
return isEnglish ( locale )
? "Moisture is building while precipitation risk is already notable; watch for showers to cap daytime heating."
: "水汽在累积且降水风险已抬升,需关注阵雨对午后增温的压制。" ;
}
if ( dewDelta <= - 1.2 && tempDelta <= 0 ) {
return isEnglish ( locale )
? "Drier low-level air is arriving together with softer temperature, which leans away from warm-moist support."
: "低层空气在转干且温度偏弱,暖湿支撑正在减弱。" ;
}
return isEnglish ( locale )
? "Use dew-point change to judge whether low-level warm-moist transport is strengthening or fading."
: "露点变化主要用于判断低层暖湿输送是在增强还是减弱。" ;
})();
const pressureNote = (() => {
if ( pressureDelta >= 1.2 && tempDelta <= - 0.8 ) {
return isEnglish ( locale )
? "Pressure rebound with cooling usually points to a cooler push or frontal suppression."
: "气压回升且温度走弱,更像冷空气压入或锋面压温。" ;
}
if ( pressureDelta <= - 1.0 && tempDelta >= 0.8 ) {
return isEnglish ( locale )
? "Pressure is softening while temperature rises, a setup less hostile to warming."
: "气压走低同时温度抬升,对增温的压制相对减弱。" ;
}
return isEnglish ( locale )
? "Pressure change is used as a supporting signal for cold-air push versus warming resilience."
: "气压变化更适合作为冷空气压入或增温韧性的辅助判断。" ;
})();
const windNote = (() => {
if ( firstBucket !== lastBucket && lastBucket === "southerly" ) {
return isEnglish ( locale )
? "Wind turns toward a southerly regime, which is more favorable for warming."
: "风向转偏南,更有利于增温。" ;
}
if ( firstBucket !== lastBucket && lastBucket === "northerly" ) {
return isEnglish ( locale )
? "Wind turns toward a northerly regime, which is more favorable for cooling."
: "风向转偏北,更有利于降温。" ;
}
if ( lastBucket === "southerly" ) {
return isEnglish ( locale )
? "Low-level flow remains southerly, so warm advection has not been disrupted."
: "低层风维持偏南,暖平流支撑尚未被破坏。" ;
}
if ( lastBucket === "northerly" ) {
return isEnglish ( locale )
? "Low-level flow remains northerly, so cooling-side support is still present."
: "低层风维持偏北,降温侧支撑仍在。" ;
}
return isEnglish ( locale )
? "Wind-direction change matters most when it crosses into southerly or northerly buckets."
: "风向变化最关键的是是否跨入偏南或偏北风桶。" ;
})();
const precipNote = (() => {
if ( precipMax >= 60 ) {
return isEnglish ( locale )
2026-04-11 10:16:00 +08:00
? ` ${ precipWindowSummary } Cloud/rain suppression can materially change the peak outcome.`
: ` ${ precipWindowSummary } 降水风险已高到足以显著改变峰值兑现结果,需要重点防压温。` ;
2026-03-21 18:54:23 +08:00
}
if ( precipMax >= 40 ) {
return isEnglish ( locale )
2026-04-11 10:16:00 +08:00
? ` ${ precipWindowSummary } Watch whether cloud and showers interrupt daytime heating.`
: ` ${ precipWindowSummary } 需要关注云系和阵雨是否打断白天增温。` ;
2026-03-21 18:54:23 +08:00
}
return isEnglish ( locale )
? "Precipitation risk remains limited and is used mainly as a suppression check."
: "降水风险暂时有限,主要作为压温风险校验项。" ;
})();
const metrics = [
{
label : isEnglish ( locale ) ? "Temperature delta" : "温度变化" ,
note : isEnglish ( locale )
? `Official Open-Meteo hourly data; window: ${ windowText } `
: `官方 Open-Meteo 小时数据;计算窗口: ${ windowText } ` ,
tone : tempDelta >= 0.8 ? "warm" : tempDelta <= - 0.8 ? "cold" : "" ,
value : formatDelta ( tempDelta , detail . temp_symbol ),
},
{
label : isEnglish ( locale ) ? "Dew point delta" : "露点变化" ,
note : dewNote ,
tone : dewDelta >= 0.8 ? "warm" : dewDelta <= - 0.8 ? "cold" : "" ,
value : formatDelta ( dewDelta , detail . temp_symbol ),
},
{
label : isEnglish ( locale ) ? "Pressure delta" : "气压变化" ,
note : pressureNote ,
tone : pressureDelta >= 1 ? "cold" : pressureDelta <= - 1 ? "warm" : "" ,
value : formatDelta ( pressureDelta , " hPa" ),
},
{
label : isEnglish ( locale ) ? "Wind-direction evolution" : "风向演变" ,
note : windNote ,
value : ` ${ bucketLabel ( firstBucket , locale ) } -> ${ bucketLabel ( lastBucket , locale ) } ` ,
},
{
2026-04-11 10:16:00 +08:00
label : isEnglish ( locale ) ? "Precip window" : "降水窗口" ,
2026-03-21 18:54:23 +08:00
note : precipNote ,
tone : precipMax >= 50 ? "cold" : "" ,
2026-04-11 10:16:00 +08:00
value : primaryPrecipWindow
? ` ${ precipWindowLabel } · ${ Math . round ( precipMax ) } %`
: ` ${ Math . round ( precipMax ) } %` ,
2026-03-21 18:54:23 +08:00
},
{
2026-04-07 10:17:17 +08:00
label : isEnglish ( locale ) ? "Forecast cloud-cover delta" : "预测云量增幅" ,
2026-03-21 18:54:23 +08:00
note : cloudNote ,
tone :
cloudDelta >= 15 && tempDelta >= 0
? "warm"
: cloudDelta >= 15 && tempDelta < 0
? "cold"
: "" ,
value : formatDelta ( cloudDelta , "%" ),
},
];
if ( backendNotes . length ) {
2026-03-21 19:01:14 +08:00
const normalizedSummary = backendSummary . trim ();
const alignedNotes = backendNotes . filter (
( note ) => String ( note || "" ). trim () !== normalizedSummary ,
);
alignedNotes . slice ( 0 , metrics . length ). forEach (( note , index ) => {
2026-03-21 18:54:23 +08:00
if ( ! note ) return ;
metrics [ index ] = {
... metrics [ index ],
note ,
};
});
}
2026-03-09 10:36:03 +08:00
return {
confidence ,
label ,
2026-03-21 18:54:23 +08:00
metrics ,
2026-03-24 01:28:45 +08:00
upperAirMetrics ,
2026-03-24 01:49:00 +08:00
upperAirSummary ,
2026-04-11 10:16:00 +08:00
precipOverlapLevel ,
precipWindowLabel ,
2026-03-09 10:36:03 +08:00
precipMax ,
score ,
2026-03-24 02:58:29 +08:00
summary : combinedSummary || backendSummary || summary ,
2026-03-25 15:22:41 +08:00
summaryLines ,
2026-03-09 10:36:03 +08:00
weatherGovPeriods ,
};
}
2026-03-10 04:45:40 +08:00
export function getFutureModalView (
detail : CityDetail ,
dateStr : string ,
locale : Locale = "zh-CN" ,
) {
2026-03-09 10:36:03 +08:00
const forecastEntry =
detail . forecast ? . daily ? . find (( item ) => item . date === dateStr ) || null ;
const dailyModel = detail . multi_model_daily ? .[ dateStr ] || {};
const probabilities = dailyModel . probabilities || [];
const totalProbability = probabilities . reduce (( sum , item ) => {
const probability = Number ( item . probability );
return Number . isFinite ( probability ) ? sum + probability : sum ;
}, 0 );
const weightedProbability = probabilities . reduce (( sum , item ) => {
const value = Number ( item . value );
const probability = Number ( item . probability );
if ( ! Number . isFinite ( value ) || ! Number . isFinite ( probability )) {
return sum ;
}
return sum + value * probability ;
}, 0 );
const mu = totalProbability > 0 ? weightedProbability / totalProbability : null ;
const deb = dailyModel . deb ? . prediction ?? forecastEntry ? . max_temp ?? null ;
return {
deb ,
forecastEntry ,
2026-03-10 04:45:40 +08:00
front : computeFrontTrendSignal ( detail , dateStr , locale ),
2026-03-09 10:36:03 +08:00
models : dailyModel.models || {},
mu : Number.isFinite ( Number ( mu )) ? Number ( mu ) : null ,
probabilities ,
slice : getFutureSlice ( detail , dateStr ),
};
}
2026-03-10 04:45:40 +08:00
export function getShortTermNowcastLines (
detail : CityDetail ,
dateStr : string ,
locale : Locale = "zh-CN" ,
) {
2026-03-09 10:36:03 +08:00
const slice = getFutureSlice ( detail , dateStr );
if ( dateStr !== detail . local_date ) {
const afternoon = slice . filter (( point ) => {
const hour = Number . parseInt ( String ( point . label ). split ( ":" )[ 0 ], 10 );
return Number . isFinite ( hour ) && hour >= 12 && hour <= 18 ;
});
const target = afternoon . length ? afternoon : slice ;
if ( ! target . length ) {
return [
2026-03-10 04:45:40 +08:00
[ isEnglish ( locale ) ? "Target date" : "目标日期" , dateStr ],
[
isEnglish ( locale ) ? "Peak window" : "峰值窗口" ,
isEnglish ( locale )
? "No sufficient hourly forecast data for target-day peak-window diagnostics."
: "暂无足够的小时级 forecast 数据,无法生成目标日午后峰值窗口判断。" ,
],
2026-03-09 10:36:03 +08:00
] as const ;
}
const maxIndex = target . reduce (( bestIndex , point , index , array ) => {
const temp = Number ( point . temp );
const bestTemp = Number ( array [ bestIndex ] ? . temp );
if ( ! Number . isFinite ( temp )) return bestIndex ;
if ( ! Number . isFinite ( bestTemp ) || temp > bestTemp ) return index ;
return bestIndex ;
}, 0 );
const peakSlice = target . slice (
Math . max ( 0 , maxIndex - 1 ),
Math . min ( target . length , maxIndex + 2 ),
);
const start = peakSlice [ 0 ];
const end = peakSlice [ peakSlice . length - 1 ];
const peakPoint = target [ maxIndex ] || end ;
const startTemp = Number ( start . temp );
const endTemp = Number ( end . temp );
const startDew = Number ( start . dewPoint );
const endDew = Number ( end . dewPoint );
const startPressure = Number ( start . pressure );
const endPressure = Number ( end . pressure );
const precipValues = peakSlice
. map (( point ) => Number ( point . precipProb ))
. filter ( Number . isFinite );
const cloudValues = peakSlice
. map (( point ) => Number ( point . cloudCover ))
. filter ( Number . isFinite );
const maxPrecip = precipValues . length ? Math . max (... precipValues ) : 0 ;
const maxCloud = cloudValues . length ? Math . max (... cloudValues ) : 0 ;
return [
2026-03-10 04:45:40 +08:00
[ isEnglish ( locale ) ? "Target date" : "目标日期" , dateStr ],
2026-03-09 10:36:03 +08:00
[
2026-03-10 04:45:40 +08:00
isEnglish ( locale ) ? "Peak window" : "峰值窗口" ,
isEnglish ( locale )
? ` ${ start . label } - ${ end . label } (prefer 12:00-18:00)`
: ` ${ start . label } - ${ end . label } (优先取 12:00-18:00) ` ,
],
[
isEnglish ( locale ) ? "Peak estimate" : "峰值预估" ,
2026-03-09 10:36:03 +08:00
` ${ Number . isFinite ( Number ( peakPoint . temp )) ? Number ( peakPoint . temp ). toFixed ( 1 ) : "--" }${ detail . temp_symbol } @ ${ peakPoint . label || "--" } ` ,
],
[
2026-03-10 04:45:40 +08:00
isEnglish ( locale ) ? "Window temperature" : "窗口温度" ,
` ${ Number . isFinite ( startTemp ) ? startTemp . toFixed ( 1 ) : "--" }${ detail . temp_symbol } -> ${ Number . isFinite ( endTemp ) ? endTemp . toFixed ( 1 ) : "--" }${ detail . temp_symbol } ( ${ formatDelta ( endTemp - startTemp , detail . temp_symbol ) } )` ,
2026-03-09 10:36:03 +08:00
],
[
2026-03-10 04:45:40 +08:00
isEnglish ( locale ) ? "Dew-point delta" : "露点变化" ,
isEnglish ( locale )
? ` ${ formatDelta ( endDew - startDew , detail . temp_symbol ) } for diagnosing warm/wet transport in afternoon.`
: ` ${ formatDelta ( endDew - startDew , detail . temp_symbol ) } ,用于判断午后暖湿输送是否增强。` ,
],
[
isEnglish ( locale ) ? "Wind shift" : "风向演变" ,
isEnglish ( locale )
? ` ${ bucketLabel ( trendBucketFromDir ( start . windDir ), locale ) } -> ${ bucketLabel ( trendBucketFromDir ( end . windDir ), locale ) } around peak window.`
: ` ${ bucketLabel ( trendBucketFromDir ( start . windDir ), locale ) } -> ${ bucketLabel ( trendBucketFromDir ( end . windDir ), locale ) } ,关注峰值前后是否转南风或回摆北风。` ,
],
[
isEnglish ( locale ) ? "Pressure delta" : "气压变化" ,
isEnglish ( locale )
? ` ${ formatDelta ( endPressure - startPressure , " hPa" ) } (higher pressure usually favors cold-air push).`
: ` ${ formatDelta ( endPressure - startPressure , " hPa" ) } ,上升更偏向冷空气压入。` ,
],
[
isEnglish ( locale ) ? "Precip / cloud" : "降水 / 云量" ,
isEnglish ( locale )
? ` ${ Math . round ( maxPrecip ) } % / ${ Math . round ( maxCloud ) } % for cloud-suppression judgement around peak hours.`
: ` ${ Math . round ( maxPrecip ) } % / ${ Math . round ( maxCloud ) } %,用于判断峰值时段是否受云系压制。` ,
2026-03-09 10:36:03 +08:00
],
] as const ;
}
const recent = Array . isArray ( detail . metar_recent_obs )
? detail . metar_recent_obs . slice ( - 4 )
: [];
const nearby = Array . isArray ( detail . mgm_nearby ) ? detail . mgm_nearby : [];
2026-03-21 21:32:54 +08:00
const nearbySource = String ( detail . nearby_source || "" ). toLowerCase ();
2026-03-10 04:45:40 +08:00
const sourceLabel =
2026-03-30 19:03:13 +08:00
nearbySource === "mgm" || isTurkishMgmCity ( detail )
2026-03-10 04:45:40 +08:00
? isEnglish ( locale )
? "MGM nearby stations"
: "MGM 周边站"
2026-03-21 21:32:54 +08:00
: nearbySource === "official_cluster"
? isEnglish ( locale )
? "Official nearby stations"
: "官方周边站"
2026-03-10 04:45:40 +08:00
: isEnglish ( locale )
? "METAR nearby stations"
: "METAR 周边站" ;
2026-03-09 10:36:03 +08:00
const currentTemp = Number ( detail . current ? . temp );
const recentTemps = recent
. map (( point ) => Number ( point . temp ))
. filter (( value ) => Number . isFinite ( value ));
const baseline = recentTemps . length ? recentTemps [ 0 ] : currentTemp ;
const shortDelta =
Number . isFinite ( currentTemp ) && Number . isFinite ( baseline )
? currentTemp - baseline
: 0 ;
let nearbyLead : { diff : number ; name : string ; temp : number } | null = null ;
for ( const station of nearby ) {
const temp = Number ( station . temp );
if ( ! Number . isFinite ( temp ) || ! Number . isFinite ( currentTemp )) continue ;
const diff = temp - currentTemp ;
if ( ! nearbyLead || Math . abs ( diff ) > Math . abs ( nearbyLead . diff )) {
nearbyLead = {
diff ,
2026-03-10 04:45:40 +08:00
name :
station.name ||
station . icao ||
( isEnglish ( locale ) ? "Nearby station" : "周边站" ),
2026-03-09 10:36:03 +08:00
temp ,
};
}
}
const rows : Array < readonly [ string , string ] > = [
2026-03-10 04:45:40 +08:00
[
isEnglish ( locale ) ? "Primary station" : "当前主站" ,
` ${ detail . current ? . temp ?? "--" }${ detail . temp_symbol } @ ${ detail . current ? . obs_time || "--" } ` ,
],
[
isEnglish ( locale ) ? "Raw METAR" : "原始 METAR" ,
detail . current ? . raw_metar || ( isEnglish ( locale ) ? "N/A" : "暂无" ),
],
[
isEnglish ( locale ) ? "Next 0-2h" : "近 0-2 小时" ,
isEnglish ( locale )
? ` ${ formatDelta ( shortDelta , detail . temp_symbol ) } based on latest METAR sequence short-term momentum.`
: ` ${ formatDelta ( shortDelta , detail . temp_symbol ) } ,依据最近 METAR 序列判断短时动量。` ,
],
[
sourceLabel ,
isEnglish ( locale )
? ` ${ nearby . length } stations joined the nearby scan.`
: ` ${ nearby . length } 个站点参与邻近监控。` ,
],
2026-03-09 10:36:03 +08:00
];
if ( nearbyLead ) {
2026-03-10 04:45:40 +08:00
const tone = isEnglish ( locale )
? nearbyLead . diff > 0
? "warmer"
: nearbyLead . diff < 0
? "cooler"
: "flat"
: nearbyLead . diff > 0
? "偏暖"
: nearbyLead . diff < 0
? "偏冷"
: "持平" ;
2026-03-09 10:36:03 +08:00
rows . push ([
2026-03-10 04:45:40 +08:00
isEnglish ( locale ) ? "Leading station" : "领先站" ,
isEnglish ( locale )
? ` ${ nearbyLead . name } ${ nearbyLead . temp }${ detail . temp_symbol } , relative to primary station ${ formatDelta ( nearbyLead . diff , detail . temp_symbol ) } ( ${ tone } ).`
: ` ${ nearbyLead . name } ${ nearbyLead . temp }${ detail . temp_symbol } ,相对主站 ${ formatDelta ( nearbyLead . diff , detail . temp_symbol ) } ( ${ tone } )。` ,
2026-03-09 10:36:03 +08:00
]);
}
return rows ;
}
export function getHistorySummary (
history : HistoryPoint [],
cityLocalDate? : string | null ,
) {
2026-03-17 23:15:13 +08:00
const toFinite = ( value : unknown ) : number | null => {
const numeric = Number ( value );
return Number . isFinite ( numeric ) ? numeric : null ;
};
const isExcludedModel = ( name : string ) =>
String ( name || "" ). toLowerCase (). includes ( "meteoblue" );
2026-03-09 10:36:03 +08:00
const cutoff = new Date ();
cutoff . setHours ( 0 , 0 , 0 , 0 );
cutoff . setDate ( cutoff . getDate () - 14 );
const recentData = history . filter (( row ) => {
if ( ! row ? . date ) return false ;
const rowDate = new Date ( ` ${ row . date } T00:00:00` );
return ! Number . isNaN ( rowDate . getTime ()) && rowDate >= cutoff ;
});
const settledData = recentData . filter (( row ) => {
if ( ! row ? . date ) return false ;
return cityLocalDate
? row . date < cityLocalDate
: row.date < new Date (). toISOString (). slice ( 0 , 10 );
});
2026-03-24 00:04:24 +08:00
const comparableSettledData = settledData . filter (( row ) => {
const actual = toFinite ( row . actual );
const deb = toFinite ( row . deb );
return actual != null && deb != null ;
});
2026-03-09 10:36:03 +08:00
let hits = 0 ;
const debErrors : number [] = [];
2026-03-17 23:15:13 +08:00
const modelErrors : Record < string , number [] > = {};
2026-03-24 00:04:24 +08:00
comparableSettledData . forEach (( row ) => {
2026-03-17 23:15:13 +08:00
const actual = toFinite ( row . actual );
const deb = toFinite ( row . deb );
2026-03-24 00:04:24 +08:00
if ( actual == null || deb == null ) return ;
debErrors . push ( Math . abs ( actual - deb ));
if ( wuRound ( actual ) === wuRound ( deb )) {
hits += 1 ;
2026-03-09 10:36:03 +08:00
}
2026-03-17 23:15:13 +08:00
const forecasts = row . forecasts || {};
Object . entries ( forecasts ). forEach (([ modelName , modelValue ]) => {
if ( isExcludedModel ( modelName )) return ;
const mv = toFinite ( modelValue );
if ( actual == null || mv == null ) return ;
if ( ! modelErrors [ modelName ]) {
modelErrors [ modelName ] = [];
}
modelErrors [ modelName ]. push ( Math . abs ( actual - mv ));
});
2026-03-09 10:36:03 +08:00
});
2026-03-17 23:15:13 +08:00
const modelMaeList = Object . entries ( modelErrors )
. map (([ name , errors ]) => ({
mae :
errors.length > 0
? errors . reduce (( sum , value ) => sum + value , 0 ) / errors.length
: Number.POSITIVE_INFINITY ,
model : name ,
sampleCount : errors.length ,
}))
. filter (( row ) => Number . isFinite ( row . mae ) && row . sampleCount > 0 )
. sort (( a , b ) => a . mae - b . mae );
const primaryModelMaeList = modelMaeList . filter (( row ) => row . sampleCount >= 2 );
const bestModel = ( primaryModelMaeList [ 0 ] || modelMaeList [ 0 ]) ?? null ;
const bestModelName = bestModel ? . model || null ;
const bestModelMae = bestModel ? Number ( bestModel . mae . toFixed ( 1 )) : null ;
const bestModelSeries = recentData . map (( row ) =>
bestModelName ? toFinite ( row . forecasts ? .[ bestModelName ]) : null ,
);
let debWinDaysVsBest = 0 ;
let debVsBestComparableDays = 0 ;
if ( bestModelName ) {
2026-03-24 00:04:24 +08:00
comparableSettledData . forEach (( row ) => {
2026-03-17 23:15:13 +08:00
const actual = toFinite ( row . actual );
const deb = toFinite ( row . deb );
const bestModelVal = toFinite ( row . forecasts ? .[ bestModelName ]);
if ( actual == null || deb == null || bestModelVal == null ) return ;
debVsBestComparableDays += 1 ;
if ( Math . abs ( deb - actual ) <= Math . abs ( bestModelVal - actual )) {
debWinDaysVsBest += 1 ;
}
});
}
2026-03-29 21:09:16 +08:00
const mgmSettledCount = settledData . reduce (( count , row ) => {
return toFinite ( row . mgm ) != null ? count + 1 : count ;
}, 0 );
const mgmSeriesComplete =
settledData . length >= 2 && mgmSettledCount === settledData . length ;
2026-03-29 21:21:52 +08:00
const mgmSeries = mgmSeriesComplete
? recentData . map (( row ) => row . mgm ?? null )
: recentData . map (() => null );
2026-03-29 21:09:16 +08:00
2026-03-09 10:36:03 +08:00
return {
dates : recentData.map (( row ) => row . date ),
debMae : debErrors.length
? Number (
(
debErrors . reduce (( sum , value ) => sum + value , 0 ) / debErrors . length
). toFixed ( 1 ),
)
: null ,
debs : recentData.map (( row ) => row . deb ),
2026-03-17 23:15:13 +08:00
bestModelName ,
bestModelMae ,
bestModelSeries ,
modelMaeRanks : modelMaeList.map (( row ) => ({
model : row.model ,
mae : Number ( row . mae . toFixed ( 1 )),
sampleCount : row.sampleCount ,
})),
debWinDaysVsBest ,
debVsBestComparableDays ,
debWinRateVsBest :
debVsBestComparableDays > 0
? Number ((( debWinDaysVsBest / debVsBestComparableDays ) * 100 ). toFixed ( 0 ))
: null ,
2026-03-09 10:36:03 +08:00
hitRate : debErrors.length
? Number ((( hits / debErrors . length ) * 100 ). toFixed ( 0 ))
: null ,
2026-03-29 21:09:16 +08:00
mgmSeriesComplete ,
2026-03-29 21:21:52 +08:00
mgms : mgmSeries ,
2026-03-09 10:36:03 +08:00
recentData ,
2026-03-24 00:04:24 +08:00
settledCount : comparableSettledData.length ,
2026-03-09 10:36:03 +08:00
actuals : recentData.map (( row ) => row . actual ),
};
}
2026-04-16 15:19:31 +08:00
function toFiniteNumber ( value : unknown ) : number | null {
const numeric = Number ( value );
return Number . isFinite ( numeric ) ? numeric : null ;
}
function asRecord ( value : unknown ) : Record < string , unknown > | null {
return value && typeof value === "object"
? ( value as Record < string , unknown >)
: null ;
}
function firstFiniteNumber ( values : unknown []) {
for ( const value of values ) {
const numeric = toFiniteNumber ( value );
if ( numeric != null ) return numeric ;
}
return null ;
}
function firstNonEmptyString ( values : unknown []) {
for ( const value of values ) {
const text = String ( value ?? "" ). trim ();
if ( text ) return text ;
}
return null ;
}
function formatObservationUpdate ( value : unknown , locale : Locale ) {
const raw = String ( value ?? "" ). trim ();
if ( ! raw ) return null ;
const looksDated =
/^\d{4}-\d{2}-\d{2}/ . test ( raw ) ||
raw . includes ( "T" ) ||
/[+-]\d{2}:?\d{2}$/ . test ( raw );
if ( looksDated ) {
const parsed = new Date ( raw );
if ( ! Number . isNaN ( parsed . getTime ())) {
return new Intl . DateTimeFormat ( isEnglish ( locale ) ? "en-US" : "zh-CN" , {
day : "2-digit" ,
hour : "2-digit" ,
hour12 : false ,
minute : "2-digit" ,
month : "2-digit" ,
})
. format ( parsed )
. replace ( "," , "" );
}
}
return normalizeHm ( raw ) || raw ;
}
2026-04-16 18:21:51 +08:00
function localObservationTimeCandidate ( value : unknown ) {
const raw = String ( value ?? "" ). trim ();
if ( ! raw || raw . includes ( "T" ) || /^\d{4}-\d{2}-\d{2}/ . test ( raw )) {
return "" ;
}
return normalizeHm ( raw ) || raw ;
}
2026-04-16 15:19:31 +08:00
function getOfficialObservationCandidates ( detail : CityDetail ) {
const officialNearby = Array . isArray ( detail . official_nearby )
? detail . official_nearby
: [];
const mgmNearby = Array . isArray ( detail . mgm_nearby ) ? detail . mgm_nearby : [];
const officialAnchor =
officialNearby . find (
( station ) => station . is_settlement_anchor || station . is_airport_station ,
) || officialNearby [ 0 ];
return {
centerStation : detail.center_station_candidate ,
mgmNearby ,
officialAnchor ,
officialNearby ,
};
}
function getObservedTemperatureProfile ( detail : CityDetail , locale : Locale ) {
2026-04-16 15:53:00 +08:00
const { mgmNearby } = getOfficialObservationCandidates ( detail );
const currentSource = String (
detail . current ? . settlement_source ||
detail . current ? . settlement_source_label ||
"" ,
)
. trim ()
. toLowerCase ();
const isNmcCurrent = currentSource === "nmc" || currentSource . includes ( "nmc" );
const isNmcAirport = String ( detail . airport_primary ? . source_label || "" )
. trim ()
. toLowerCase ()
. includes ( "nmc" );
const candidates = [
{
sourceLabel : detail.airport_current?.source_label || "METAR" ,
temp : detail.airport_current?.temp ,
},
{
sourceLabel : detail.airport_primary?.source_label || "METAR" ,
temp : isNmcAirport ? null : detail . airport_primary ? . temp ,
},
{
sourceLabel : detail.current?.settlement_source_label ,
temp : isNmcCurrent ? null : detail . current ? . temp ,
},
{
sourceLabel : mgmNearby [ 0 ] ? . source_label ,
temp : mgmNearby [ 0 ] ? . temp ,
},
];
const selected = candidates . find (( candidate ) =>
Number . isFinite ( Number ( candidate . temp )),
);
2026-04-16 15:19:31 +08:00
2026-04-16 15:53:00 +08:00
if ( ! selected ) {
2026-04-16 15:19:31 +08:00
return isEnglish ( locale ) ? "Unavailable" : "未提供" ;
}
2026-04-16 15:53:00 +08:00
const observedTemp = Number ( selected . temp );
2026-04-16 15:19:31 +08:00
const sourceLabel = firstNonEmptyString ([
2026-04-16 15:53:00 +08:00
selected . sourceLabel ,
2026-04-16 15:19:31 +08:00
getObservationSourceTag ( detail ),
]);
const rounded =
Math . abs ( observedTemp - Math . round ( observedTemp )) < 0.05
? String ( Math . round ( observedTemp ))
: observedTemp . toFixed ( 1 );
return ` ${ rounded }${ detail . temp_symbol || "°C" }${
sourceLabel ? ` · ${ sourceLabel } ` : ""
} ` ;
}
function getObservationUpdateProfile ( detail : CityDetail , locale : Locale ) {
2026-04-16 15:53:00 +08:00
const { mgmNearby } = getOfficialObservationCandidates ( detail );
2026-04-16 15:19:31 +08:00
const mgmFirstRecord = asRecord ( mgmNearby [ 0 ]);
2026-04-16 15:53:00 +08:00
const currentSource = String (
detail . current ? . settlement_source ||
detail . current ? . settlement_source_label ||
"" ,
)
. trim ()
. toLowerCase ();
const isNmcCurrent = currentSource === "nmc" || currentSource . includes ( "nmc" );
2026-04-16 15:19:31 +08:00
const rawValue = firstNonEmptyString ([
2026-04-16 18:21:51 +08:00
isNmcCurrent ? "" : detail . current ? . obs_time ,
localObservationTimeCandidate ( detail . airport_primary ? . obs_time ),
localObservationTimeCandidate ( detail . airport_current ? . obs_time ),
localObservationTimeCandidate ( mgmFirstRecord ? . obs_time ),
localObservationTimeCandidate ( mgmFirstRecord ? . time ),
2026-04-16 15:19:31 +08:00
detail . airport_primary ? . report_time ,
detail . airport_current ? . report_time ,
2026-04-16 15:53:00 +08:00
isNmcCurrent ? "" : detail . current ? . report_time ,
2026-04-16 15:19:31 +08:00
mgmFirstRecord ? . report_time ,
detail . updated_at ,
]);
return (
formatObservationUpdate ( rawValue , locale ) ||
( isEnglish ( locale ) ? "Unavailable" : "未提供" )
);
}
2026-03-10 04:45:40 +08:00
export function getCityProfileStats ( detail : CityDetail , locale : Locale = "zh-CN" ) {
2026-03-09 10:36:03 +08:00
const risk = detail . risk || {};
const nearbyCount = Array . isArray ( detail . mgm_nearby ) ? detail.mgm_nearby.length : 0 ;
2026-04-09 20:19:07 +08:00
const nearbySource = String ( detail . nearby_source || "" ). trim (). toLowerCase ();
2026-03-18 00:41:54 +08:00
const sourceCode = getObservationSourceCode ( detail );
2026-03-25 17:14:25 +08:00
const isOfficialSource =
sourceCode === "hko" ||
sourceCode === "cwa" ||
2026-04-17 00:19:47 +08:00
sourceCode === "noaa" ;
2026-03-18 00:41:54 +08:00
const sourceDisplay = (() => {
if ( sourceCode === "hko" ) {
return isEnglish ( locale )
? "Hong Kong Observatory (HKO)"
: "香港天文台 (HKO)" ;
}
if ( sourceCode === "cwa" ) {
return isEnglish ( locale )
? "Central Weather Administration (CWA)"
: "交通部中央气象署 (CWA)" ;
}
2026-03-23 14:39:41 +08:00
if ( sourceCode === "noaa" ) {
2026-03-27 20:58:38 +08:00
const noaaCode = getNoaaStationCode ( detail );
const noaaName = getNoaaStationName ( detail );
2026-03-23 14:39:41 +08:00
return isEnglish ( locale )
2026-04-06 05:38:41 +08:00
? ` ${ noaaName }${ noaaCode ? ` ( ${ noaaCode } )` : "" } `
: ` ${ noaaName }${ noaaCode ? `( ${ noaaCode } ) ` : "" } ` ;
2026-03-23 14:39:41 +08:00
}
2026-04-17 00:19:47 +08:00
if ( sourceCode === "wunderground" ) {
const icao = String ( detail . risk ? . icao || detail . current ? . station_code || "" )
. trim ()
. toUpperCase ();
const stationName = String (
detail . current ? . station_name || detail . risk ? . airport || "" ,
). trim ();
return ` ${ stationName || icao || "Airport" }${ icao ? ` ( ${ icao } METAR)` : " METAR" } ` ;
}
2026-04-06 05:38:41 +08:00
const stationName = String (
detail . current ? . station_name || detail . risk ? . airport || "" ,
). trim ();
const stationCode = String (
detail . current ? . station_code || detail . risk ? . icao || "" ,
). trim ();
if ( stationName ) {
return ` ${ stationName }${ stationCode ? ` ( ${ stationCode } )` : "" } ` ;
2026-03-25 17:14:25 +08:00
}
2026-03-18 00:41:54 +08:00
const tag = getObservationSourceTag ( detail );
if ( sourceCode === "mgm" ) {
return isEnglish ( locale ) ? `MGM ( ${ tag } )` : `MGM ( ${ tag } )` ;
}
if ( risk . airport && risk . icao ) return ` ${ risk . airport } ( ${ risk . icao } )` ;
if ( risk . airport ) return String ( risk . airport );
return isEnglish ( locale ) ? "No profile" : "暂无档案" ;
})();
2026-03-09 10:36:03 +08:00
2026-04-09 20:19:07 +08:00
const rows = [
2026-03-09 10:36:03 +08:00
{
2026-03-18 00:41:54 +08:00
label : isOfficialSource
? isEnglish ( locale )
2026-04-06 05:38:41 +08:00
? "Settlement station"
: "结算站点"
2026-03-18 00:41:54 +08:00
: isEnglish ( locale )
? "Settlement airport"
: "结算机场" ,
value : sourceDisplay ,
2026-03-09 10:36:03 +08:00
},
{
2026-03-18 00:41:54 +08:00
label : isOfficialSource
? isEnglish ( locale )
? "Reference distance"
: "参考距离"
: isEnglish ( locale )
? "Station distance"
: "站点距离" ,
2026-03-09 10:36:03 +08:00
value :
risk.distance_km != null && Number . isFinite ( Number ( risk . distance_km ))
? ` ${ risk . distance_km } km`
2026-03-10 04:45:40 +08:00
: isEnglish ( locale )
? "Not marked"
: "未标注" ,
2026-03-09 10:36:03 +08:00
},
2026-04-16 15:19:31 +08:00
{
label : isEnglish ( locale ) ? "Observed temp" : "实测温度" ,
value : getObservedTemperatureProfile ( detail , locale ),
},
2026-03-09 10:36:03 +08:00
{
2026-03-10 04:45:40 +08:00
label : isEnglish ( locale ) ? "Observation update" : "观测更新" ,
2026-04-16 15:19:31 +08:00
value : getObservationUpdateProfile ( detail , locale ),
2026-03-09 10:36:03 +08:00
},
{
2026-03-10 04:45:40 +08:00
label : isEnglish ( locale ) ? "Nearby stations" : "周边站点" ,
value :
nearbyCount > 0
? isEnglish ( locale )
? ` ${ nearbyCount } participating stations`
: ` ${ nearbyCount } 个参与监控`
: isEnglish ( locale )
? "No nearby stations"
: "暂无周边站" ,
2026-03-09 10:36:03 +08:00
},
];
2026-04-09 20:19:07 +08:00
if ( nearbyCount > 0 ) {
rows . push ({
label : isEnglish ( locale ) ? "Nearby source" : "周边站来源" ,
value :
nearbySource === "kma"
? isEnglish ( locale )
? "KMA official stations"
: "KMA 官方站"
: nearbySource === "official_cluster"
? isEnglish ( locale )
? "Official station cluster"
: "官方站簇"
: nearbySource === "mgm"
? "MGM"
: isEnglish ( locale )
? "Airport / METAR network"
: "机场 / METAR 网络" ,
});
}
if ( nearbySource === "kma" && detail . airport_current ? . temp != null ) {
const airportLabel =
String (
detail . airport_current . station_label ||
detail . airport_current . station_code ||
detail . risk ? . airport ||
"" ,
). trim () ||
( isEnglish ( locale ) ? "Airport station" : "机场主站" );
const airportObsTime =
String ( detail . airport_current . obs_time || "" ). trim () ||
( isEnglish ( locale ) ? "pending" : "待更新" );
const airportHigh =
detail . airport_current . max_so_far != null
? ` ${ detail . airport_current . max_so_far }${ detail . temp_symbol || "" }${
detail . airport_current . max_temp_time
? ` @ ${ detail . airport_current . max_temp_time } `
: ""
} `
: isEnglish ( locale )
? "Unavailable"
: "未提供" ;
rows . push ({
label : isEnglish ( locale ) ? "Airport reference" : "机场主站参考" ,
value : ` ${ airportLabel } : ${ detail . airport_current . temp }${
detail . temp_symbol || ""
} @ ${ airportObsTime } ` ,
});
rows . push ({
label : isEnglish ( locale ) ? "Airport high" : "机场目前最高温" ,
value : airportHigh ,
});
}
return rows ;
2026-03-09 10:36:03 +08:00
}
2026-03-10 04:45:40 +08:00
export function getSettlementRiskNarrative (
detail : CityDetail ,
locale : Locale = "zh-CN" ,
) {
2026-03-09 10:36:03 +08:00
const risk = detail . risk || {};
2026-03-18 00:41:54 +08:00
const sourceCode = getObservationSourceCode ( detail );
2026-03-25 17:14:25 +08:00
const stationTerm =
sourceCode === "hko" ||
sourceCode === "cwa" ||
2026-04-17 00:19:47 +08:00
sourceCode === "noaa"
2026-03-18 00:41:54 +08:00
? isEnglish ( locale )
? "settlement reference station"
: "结算参考站"
: isEnglish ( locale )
? "settlement airport"
: "结算机场" ;
2026-03-09 10:36:03 +08:00
const lines : string [] = [];
if ( risk . warning ) {
2026-03-10 04:45:40 +08:00
lines . push (
isEnglish ( locale )
? `Current key risk: ${ risk . warning } `
: `当前主要风险是: ${ risk . warning } ` ,
);
2026-03-09 10:36:03 +08:00
}
if ( risk . distance_km != null ) {
if ( risk . distance_km >= 60 ) {
2026-03-10 04:45:40 +08:00
lines . push (
isEnglish ( locale )
2026-03-18 00:41:54 +08:00
? `The ${ stationTerm } is far from urban core; market feel and settlement value may diverge significantly.`
: ` ${ stationTerm } 与城市核心区域距离偏大,盘面温度与结算值可能出现明显背离。` ,
2026-03-10 04:45:40 +08:00
);
2026-03-09 10:36:03 +08:00
} else if ( risk . distance_km >= 25 ) {
2026-03-10 04:45:40 +08:00
lines . push (
isEnglish ( locale )
2026-03-18 00:41:54 +08:00
? `The ${ stationTerm } has material distance from downtown; peak/overnight rhythm should prioritize the settlement station.`
: ` ${ stationTerm } 与城区存在可感知距离,午后峰值和夜间降温节奏需要优先看结算站。` ,
2026-03-10 04:45:40 +08:00
);
2026-03-09 10:36:03 +08:00
} else {
2026-03-10 04:45:40 +08:00
lines . push (
isEnglish ( locale )
2026-03-18 00:41:54 +08:00
? `The ${ stationTerm } is close enough; city feel and settlement temperature are usually more synchronized.`
: ` ${ stationTerm } 距离较近,城市体感与结算温度通常更同步。` ,
2026-03-10 04:45:40 +08:00
);
2026-03-09 10:36:03 +08:00
}
}
2026-03-30 19:03:13 +08:00
if ( isTurkishMgmCity ( detail )) {
2026-03-10 04:45:40 +08:00
lines . push (
isEnglish ( locale )
2026-03-30 19:03:13 +08:00
? "For Turkish MGM-supported cities, focus on the airport station plus MGM nearby-station linkage, not urban sensation alone."
: "对接入 MGM 的土耳其城市,需要重点看机场站与 MGM 周边站联动,不能只看城区体感。" ,
2026-03-10 04:45:40 +08:00
);
2026-03-09 10:36:03 +08:00
}
if ( detail . current ? . obs_age_min != null ) {
if ( detail . current . obs_age_min >= 45 ) {
2026-03-10 04:45:40 +08:00
lines . push (
isEnglish ( locale )
? `Current METAR is ${ detail . current . obs_age_min } minutes old. Blend nearby stations for nowcast instead of single-station snapshot.`
: `当前 METAR 已有 ${ detail . current . obs_age_min } 分钟时滞,临近判断要结合周边站而不是只看主站快照。` ,
);
2026-03-09 10:36:03 +08:00
} else {
2026-03-10 04:45:40 +08:00
lines . push (
isEnglish ( locale )
? "Primary station observation is fresh enough; short-term judgement can anchor on it."
: "当前主站观测较新,短时判断可以把主站温度作为主要锚点。" ,
);
2026-03-09 10:36:03 +08:00
}
}
return lines ;
}
2026-03-10 04:45:40 +08:00
export function getClimateDrivers ( detail : CityDetail , locale : Locale = "zh-CN" ) {
2026-03-09 10:36:03 +08:00
const drivers : Array < { label : string ; text : string } > = [];
const lat = Math . abs ( Number ( detail . lat ));
2026-03-10 04:45:40 +08:00
const nearbyCount = Array . isArray ( detail . mgm_nearby )
? detail.mgm_nearby.length
: 0 ;
const distanceKm = Number ( detail . risk ? . distance_km );
2026-03-09 10:36:03 +08:00
if ( lat >= 50 ) {
drivers . push ({
2026-03-10 04:45:40 +08:00
label : isEnglish ( locale ) ? "High-latitude cold air" : "高纬冷空气" ,
text : isEnglish ( locale )
? "At higher latitude, temperature rhythm is more affected by cold-air surges, trough passage, and seasonal radiation angle."
: "该城市位于较高纬度,温度变化更容易受到冷空气南下、短波槽和日照角度变化影响。" ,
2026-03-09 10:36:03 +08:00
});
} else if ( lat >= 35 ) {
drivers . push ({
2026-03-10 04:45:40 +08:00
label : isEnglish ( locale ) ? "Mid-latitude westerlies" : "中纬西风带" ,
text : isEnglish ( locale )
? "Temperature shifts are often controlled by frontal transitions rather than pure daytime radiation."
: "该城市主要受中纬西风带和锋面活动控制,升降温常来自气团切换,而不是单一日照变化。" ,
2026-03-09 10:36:03 +08:00
});
} else if ( lat >= 20 ) {
drivers . push ({
2026-03-10 04:45:40 +08:00
label : isEnglish ( locale ) ? "Subtropical highs" : "副热带高压" ,
text : isEnglish ( locale )
? "Subtropical ridge, clear-sky radiation and low-level warm advection often dominate warming efficiency."
: "该城市更容易受副热带高压、晴空辐射和低层暖平流影响,午后增温能力通常更强。" ,
2026-03-09 10:36:03 +08:00
});
} else {
drivers . push ({
2026-03-10 04:45:40 +08:00
label : isEnglish ( locale ) ? "Tropical moisture & convection" : "热带水汽与对流" ,
text : isEnglish ( locale )
? "Temperature and feels-like are often modulated by moisture transport, cloud convection and showers."
: "该城市偏热带环境,温度与体感常受水汽输送、云对流和阵雨触发影响。" ,
2026-03-09 10:36:03 +08:00
});
}
2026-03-10 04:45:40 +08:00
drivers . push ({
label : isEnglish ( locale ) ? "Dry-wet boundary layer" : "干湿边界层" ,
text : isEnglish ( locale )
? "Boundary-layer humidity controls daytime warming efficiency; dry boundary warms faster, wet boundary is more cloud/precip-sensitive."
: "低层干湿状态会决定午后升温效率。干空气通常升温更快,湿空气更容易受云量和降水过程抑制。" ,
});
2026-03-09 10:36:03 +08:00
2026-03-10 04:45:40 +08:00
drivers . push ({
label : isEnglish ( locale ) ? "Advection transport" : "平流输送" ,
text : isEnglish ( locale )
? "Short-term trend is usually driven by low-level air-mass transport. Persistent wind origin tends to sustain thermal direction."
: "短时趋势常由低层气团输送控制。若风向持续来自同一侧,温度通常更容易沿该方向延续。" ,
});
if ( Number . isFinite ( distanceKm ) && distanceKm >= 25 ) {
2026-03-09 10:36:03 +08:00
drivers . push ({
2026-03-10 04:45:40 +08:00
label : isEnglish ( locale ) ? "Station representativeness" : "站点代表性" ,
text : isEnglish ( locale )
? "When settlement station is not near city core, perceived temperature and settlement value may diverge."
: "结算站与城市核心区存在一定距离时,体感温度和结算温度可能分离,评估时应优先以结算站观测为准。" ,
2026-03-09 10:36:03 +08:00
});
}
if ( nearbyCount >= 4 ) {
drivers . push ({
2026-03-10 04:45:40 +08:00
label : isEnglish ( locale ) ? "Local heterogeneity" : "局地差异" ,
text : isEnglish ( locale )
? "More nearby stations suggest terrain/urban-heat heterogeneity; settlement station and downtown sensation should be evaluated separately."
: "周边可用站点较多,说明地形、城区热岛或下垫面差异可能明显,结算站与城区体感需要分开评估。" ,
2026-03-09 10:36:03 +08:00
});
}
return drivers ;
}