2026-03-10 04:45:40 +08:00
import { Locale } from "@/lib/i18n" ;
import {
AiAnalysisStructured ,
CityDetail ,
NearbyStation ,
} from "@/lib/dashboard-types" ;
2026-04-28 11:58:58 +08:00
import {
getNoaaStationCode ,
getNoaaStationName ,
getObservationSourceCode ,
getObservationSourceTag ,
getRealtimeObservationTag ,
isTurkishMgmCity ,
} from "@/lib/observation-source-utils" ;
2026-04-28 11:32:03 +08:00
import {
formatTemperatureValue ,
normalizeTemperatureLabel ,
normalizeTemperatureSymbol ,
} from "@/lib/temperature-utils" ;
2026-04-28 11:58:58 +08:00
import { formatTafMarkerType } from "@/lib/taf-utils" ;
import { normalizeHm } from "@/lib/time-utils" ;
2026-04-28 12:17:42 +08:00
import { getWeatherSummary } from "@/lib/weather-summary-utils" ;
2026-05-17 14:33:23 +08:00
import { getDisplayAirportPrimary } from "@/lib/airport-observation-display" ;
2026-04-28 11:32:03 +08:00
2026-04-28 11:58:58 +08:00
export { getTemperatureChartData } from "@/lib/chart-utils" ;
2026-04-28 12:07:43 +08:00
export { getModelView , getProbabilityView } from "@/lib/model-utils" ;
2026-04-28 11:45:34 +08:00
export { getTodayPaceView } from "@/lib/pace-utils" ;
2026-04-28 12:17:42 +08:00
export {
getHeroMetaItems ,
getRiskBadgeLabel ,
getWeatherSummary ,
translateMetar ,
} from "@/lib/weather-summary-utils" ;
2026-04-28 11:32:03 +08:00
export {
formatTemperatureValue ,
normalizeTemperatureLabel ,
normalizeTemperatureSymbol ,
} from "@/lib/temperature-utils" ;
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-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 ();
2026-05-11 18:11:03 +08:00
if ( city === "seoul" || city === "busan" ) {
return [];
}
2026-03-30 19:21:39 +08:00
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-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 )
: [];
2026-04-19 02:38:34 +08:00
const nearby = Array . isArray ( detail . official_nearby ) && detail . official_nearby . length
? detail.official_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 ;
2026-04-19 02:38:34 +08:00
let nearbyLead : { diff : number ; name : string ; temp : number ; syncText : string } | null = null ;
2026-03-09 10:36:03 +08:00
for ( const station of nearby ) {
const temp = Number ( station . temp );
2026-04-19 02:38:34 +08:00
if ( station . usable_for_intraday === false ) continue ;
2026-03-09 10:36:03 +08:00
if ( ! Number . isFinite ( temp ) || ! Number . isFinite ( currentTemp )) continue ;
const diff = temp - currentTemp ;
2026-04-19 02:38:34 +08:00
const syncStatus = String ( station . sync_status || "" ). toLowerCase ();
const syncDelta = Number ( station . time_delta_vs_anchor_minutes );
const syncText =
syncStatus === "synced"
? isEnglish ( locale )
? "time-synced"
: "时间同步"
: syncStatus === "near_realtime" || syncStatus === "lagged"
? Number . isFinite ( syncDelta )
? isEnglish ( locale )
? ` ${ Math . round ( syncDelta ) } min offset`
: `时间差 ${ Math . round ( syncDelta ) } 分钟`
: isEnglish ( locale )
? "not fully synchronized"
: "非完全同步"
: syncStatus === "unknown"
? isEnglish ( locale )
? "timing unverified"
: "时间待校验"
: "" ;
2026-03-09 10:36:03 +08:00
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 ,
2026-04-19 02:38:34 +08:00
syncText ,
2026-03-09 10:36:03 +08:00
};
}
}
2026-04-19 02:38:34 +08:00
const usableNearbyCount = nearby . filter (( station ) => station . usable_for_intraday !== false ). length ;
2026-03-09 10:36:03 +08:00
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 )
2026-04-19 02:38:34 +08:00
? ` ${ usableNearbyCount } / ${ nearby . length } stations usable for the nearby scan; station timestamps may differ.`
: ` ${ usableNearbyCount } / ${ nearby . length } 个站点可参与邻近监控;周边站观测时间可能不同步。` ,
2026-03-10 04:45:40 +08:00
],
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 )
2026-04-19 02:38:34 +08:00
? ` ${ nearbyLead . name } ${ nearbyLead . temp }${ detail . temp_symbol } , relative to primary station ${ formatDelta ( nearbyLead . diff , detail . temp_symbol ) } ( ${ tone } ) ${ nearbyLead . syncText ? `; ${ nearbyLead . syncText } ` : "" } .`
: ` ${ nearbyLead . name } ${ nearbyLead . temp }${ detail . temp_symbol } ,相对主站 ${ formatDelta ( nearbyLead . diff , detail . temp_symbol ) } ( ${ tone } ) ${ nearbyLead . syncText ? `; ${ nearbyLead . syncText } ` : "" } 。` ,
2026-03-09 10:36:03 +08:00
]);
}
return rows ;
}
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 );
2026-05-17 14:33:23 +08:00
const displayAirportPrimary = getDisplayAirportPrimary ( detail );
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-05-17 14:33:23 +08:00
const isNmcAirport = String ( displayAirportPrimary ? . source_label || "" )
2026-04-16 15:53:00 +08:00
. trim ()
. toLowerCase ()
. includes ( "nmc" );
const candidates = [
{
sourceLabel : detail.airport_current?.source_label || "METAR" ,
temp : detail.airport_current?.temp ,
},
{
2026-05-17 14:33:23 +08:00
sourceLabel : displayAirportPrimary?.source_label || "METAR" ,
temp : isNmcAirport ? null : displayAirportPrimary ? . temp ,
2026-04-16 15:53:00 +08:00
},
{
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-05-17 14:33:23 +08:00
const displayAirportPrimary = getDisplayAirportPrimary ( detail );
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 ,
2026-05-17 14:33:23 +08:00
localObservationTimeCandidate ( displayAirportPrimary ? . obs_time ),
2026-04-16 18:21:51 +08:00
localObservationTimeCandidate ( detail . airport_current ? . obs_time ),
localObservationTimeCandidate ( mgmFirstRecord ? . obs_time ),
localObservationTimeCandidate ( mgmFirstRecord ? . time ),
2026-05-17 14:33:23 +08:00
displayAirportPrimary ? . report_time ,
2026-04-16 15:19:31 +08:00
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 ;
}