2026-04-28 11:58:58 +08:00
import type { CityDetail } from "@/lib/dashboard-types" ;
import type { Locale } from "@/lib/i18n" ;
import {
getNoaaStationCode ,
getObservationSourceCode ,
getObservationSourceTag ,
getRealtimeObservationTag ,
isTurkishMgmCity ,
} from "@/lib/observation-source-utils" ;
import { normalizeTemperatureSymbol } from "@/lib/temperature-utils" ;
import { formatTafMarkerType } from "@/lib/taf-utils" ;
import {
hmToMinutes ,
interpolateSeriesAtMinutes ,
normalizeHm ,
} from "@/lib/time-utils" ;
function isEnglish ( locale : Locale ) {
return locale === "en-US" ;
}
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 getNiceTemperatureScale ( values : number [], tempSymbol? : string | null ) {
const numericValues = values . filter (( value ) => Number . isFinite ( Number ( value )));
if ( ! numericValues . length ) {
return { max : 1 , min : 0 , step : 1 };
}
const rawMin = Math . min (... numericValues );
const rawMax = Math . max (... numericValues );
const spread = Math . max ( 0.1 , rawMax - rawMin );
const isFahrenheit = normalizeTemperatureSymbol ( tempSymbol ) === "°F" ;
const padding = Math . max ( isFahrenheit ? 1.5 : 0.8 , spread * 0.12 );
const paddedMin = rawMin - padding ;
const paddedMax = rawMax + padding ;
const paddedSpread = Math . max ( 0.1 , paddedMax - paddedMin );
const candidates = isFahrenheit ? [ 1 , 2 , 5 , 10 , 20 ] : [ 0.5 , 1 , 2 , 5 , 10 ];
let step =
candidates . find (( candidate ) => candidate >= paddedSpread / 4 ) ||
candidates [ candidates . length - 1 ];
let min = Math . floor ( paddedMin / step ) * step ;
let max = Math . ceil ( paddedMax / step ) * step ;
if ( min < 0 && rawMin >= 0 && rawMin <= step * 1.25 ) min = 0 ;
if ( max <= min ) max = min + step * 4 ;
while (( max - min ) / step + 1 > 6 ) {
const nextStep = candidates . find (( candidate ) => candidate > step );
if ( ! nextStep ) break ;
step = nextStep ;
min = Math . floor ( paddedMin / step ) * step ;
max = Math . ceil ( paddedMax / step ) * step ;
if ( min < 0 && rawMin >= 0 && rawMin <= step * 1.25 ) min = 0 ;
}
return { max , min , step };
}
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-05-01 11:11:17 +08:00
function clampTemperatureDelta ( value : number , min = - 4 , max = 4 ) {
return Math . min ( Math . max ( value , min ), max );
}
function buildCalibratedFuturePath ({
observations ,
times ,
debTemps ,
currentMinutes ,
reversionMinutes ,
} : {
observations : Array < { time? : string | null ; temp? : number | null } > ;
times : string [];
debTemps : Array < number | null >;
currentMinutes : number | null ;
reversionMinutes? : number | null ;
}) {
2026-05-01 11:37:09 +08:00
if ( ! times . length || ! observations . length ) {
2026-05-01 11:11:17 +08:00
return {
adjustmentDelta : null as number | null ,
future : new Array ( times . length ). fill ( null ) as Array < number | null >,
};
}
2026-05-01 11:17:20 +08:00
const normalizedObservations = dedupeObservationItems ( observations );
const latestObservationMinute = normalizedObservations . reduce < number | null >(
( latest , item ) => {
const minute = hmToMinutes ( item . time );
if ( minute == null ) return latest ;
return latest == null ? minute : Math.max ( latest , minute );
},
null ,
);
2026-05-01 11:37:09 +08:00
if ( latestObservationMinute == null && currentMinutes == null ) {
return {
adjustmentDelta : null as number | null ,
future : new Array ( times . length ). fill ( null ) as Array < number | null >,
};
}
2026-05-01 11:17:20 +08:00
// In practice the backend `local_time` can lag the latest METAR/official
// observation by one refresh cycle. Anchor the future line to the newest
// observation when it is newer, otherwise the "no future obs" guard can
// suppress the calibrated path even though the user already sees a fresh
// green observation point on the chart.
const pathStartMinutes =
2026-05-01 11:37:09 +08:00
latestObservationMinute == null || currentMinutes == null
? latestObservationMinute ?? currentMinutes ?? 0
: Math.max ( currentMinutes , latestObservationMinute );
2026-05-01 11:17:20 +08:00
const deltas = normalizedObservations
2026-05-01 11:11:17 +08:00
. map (( item ) => {
const minute = hmToMinutes ( item . time );
const observed = Number ( item . temp );
if (
minute == null ||
2026-05-01 11:17:20 +08:00
minute > pathStartMinutes + 30 ||
2026-05-01 11:11:17 +08:00
! Number . isFinite ( observed )
) {
return null ;
}
const expected = interpolateSeriesAtMinutes ( times , debTemps , minute );
if ( expected == null || ! Number . isFinite ( expected )) return null ;
return {
delta : clampTemperatureDelta ( observed - expected ),
minute ,
};
})
. filter (
( item ) : item is { delta : number ; minute : number } => item != null ,
)
. slice ( - 3 );
if ( ! deltas . length ) {
return {
adjustmentDelta : null as number | null ,
future : new Array ( times . length ). fill ( null ) as Array < number | null >,
};
}
const weighted = deltas . reduce (
( acc , item , index ) => {
const weight = index + 1 ;
return {
total : acc.total + item . delta * weight ,
weight : acc.weight + weight ,
};
},
{ total : 0 , weight : 0 },
);
const adjustmentDelta = Number (
clampTemperatureDelta ( weighted . total / Math . max ( weighted . weight , 1 )). toFixed (
1 ,
),
);
const lastSeriesMinute = times
. map (( time ) => hmToMinutes ( time ))
. filter (( minute ) : minute is number => minute != null )
. at ( - 1 );
const returnToBaselineMinute =
2026-05-01 11:17:20 +08:00
reversionMinutes != null && reversionMinutes > pathStartMinutes
2026-05-01 11:11:17 +08:00
? reversionMinutes
2026-05-01 11:17:20 +08:00
: lastSeriesMinute != null && lastSeriesMinute > pathStartMinutes
2026-05-01 11:11:17 +08:00
? lastSeriesMinute
2026-05-01 11:17:20 +08:00
: pathStartMinutes + 6 * 60 ;
2026-05-01 11:11:17 +08:00
const future = times . map (( time , index ) => {
const minute = hmToMinutes ( time );
const base = debTemps [ index ];
if (
minute == null ||
2026-05-01 11:17:20 +08:00
minute < pathStartMinutes ||
2026-05-01 11:11:17 +08:00
base == null ||
! Number . isFinite ( base )
) {
return null ;
}
const progressToEvening = Math . min (
Math . max (
2026-05-01 11:17:20 +08:00
( minute - pathStartMinutes ) /
Math . max ( returnToBaselineMinute - pathStartMinutes , 1 ),
2026-05-01 11:11:17 +08:00
0 ,
),
1 ,
);
// Strongest right after the latest observation, then smoothly fades back
// to the unchanged DEB baseline by evening/sunset. This keeps one METAR
// point from dragging the whole-day forecast away from the base path.
const decay = Math . pow ( 1 - progressToEvening , 1.35 );
return Number (( base + adjustmentDelta * decay ). toFixed ( 1 ));
});
return { adjustmentDelta , future };
}
2026-04-28 11:58:58 +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 ;
});
}
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 ;
}
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 ,
},
];
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 ),
},
];
}
export function getTemperatureChartData (
detail : CityDetail ,
locale : Locale = "zh-CN" ,
) {
const hourly = detail . hourly || {};
const rawTimes = Array . isArray ( hourly . times ) ? hourly . times : [];
const rawTemps = Array . isArray ( hourly . temps ) ? hourly . temps : [];
const times = rawTimes
. map (( time ) => String ( time || "" ). trim ())
. filter ( Boolean );
const temps = times . map (( _ , index ) => {
const value = Number ( rawTemps [ index ]);
return Number . isFinite ( value ) ? value : null ;
});
const suppressAnkaraMgmObservation = isTurkishMgmCity ( detail );
if ( ! times . length ) return null ;
const currentIndex = findNearestTimeIndex ( times , detail . local_time );
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 . isFinite ( temp )
? 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 ,
);
const observationTag = getRealtimeObservationTag ( detail );
const observationCode = getObservationSourceCode ( detail );
const settlementSource =
observationCode === "hko" ||
observationCode === "cwa" ||
observationCode === "noaa" ||
observationCode === "wunderground" ;
const useSettlementObservationSource = settlementSource ;
const officialObservationSource =
useSettlementObservationSource
? 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 }]
: []
: [];
const currentObservationFallback = buildCurrentObservationFallback ( detail );
const minPlausibleObservationTemp = (() => {
const name = String ( detail . name || "" ). trim (). toLowerCase ();
const icao = String ( detail . risk ? . icao || "" ). trim (). toUpperCase ();
2026-05-11 16:39:04 +08:00
if ( name === "karachi" || icao === "OPKC" ) {
2026-04-28 11:58:58 +08:00
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 ;
const usingCurrentObservationFallback =
! plausibleMetarTodayObs . length &&
! plausibleTrendRecent . length &&
plausibleCurrentFallback . length > 0 ;
const currentFallbackTag =
currentObservationFallback [ 0 ] ? . sourceLabel ||
getObservationSourceTag ( detail );
const allowMetarFallback = settlementSource && observationCode !== "hko" ;
const shouldUseMetarFallback =
allowMetarFallback &&
officialObservationSource . length > 0 &&
officialObservationSource . length < 3 &&
metarObservationSource . length >= 3 ;
let usingMetarObservationSource =
! useSettlementObservationSource || shouldUseMetarFallback ;
let observationSource = useSettlementObservationSource
? shouldUseMetarFallback
? metarObservationSource
: officialObservationSource
: metarObservationSource ;
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 );
const airportMetarSource : Array < { time? : string ; temp? : number | null } > = [];
const metarFallbackTag = (() => {
const icao = String ( detail . risk ? . icao || "" ). trim (). toUpperCase ();
if ( ! icao ) return "METAR" ;
return ` ${ icao } METAR` ;
})();
const observationDisplayTag =
usingCurrentObservationFallback
? String ( currentFallbackTag ). toUpperCase ()
: observationCode === "wunderground" && usingMetarObservationSource
? metarFallbackTag
: observationCode === "wunderground"
? metarFallbackTag
: useSettlementObservationSource && shouldUseMetarFallback
? metarFallbackTag
: observationCode === "noaa"
? `NOAA ${ getNoaaStationCode ( detail ) } `
: observationTag ;
const metarPoints = new Array ( times . length ). fill ( null );
observationSource . forEach (( item ) => {
const index = findNearestTimeIndex ( times , String ( item . time || "" ));
const temp = Number ( item . temp );
if ( index >= 0 && Number . isFinite ( temp )) {
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 ), temp );
}
});
const airportMetarPoints = new Array ( times . length ). fill ( null );
airportMetarSource . forEach (( item ) => {
const index = findNearestTimeIndex ( times , String ( item . time || "" ));
const temp = Number ( item . temp );
if ( index >= 0 && Number . isFinite ( temp )) {
const existing = airportMetarPoints [ index ];
airportMetarPoints [ index ] =
existing == null ? temp : Math.max ( Number ( existing ), temp );
}
});
2026-05-01 11:11:17 +08:00
const calibrationObservationSource = dedupeObservationItems (
metarObservationSource . length ? metarObservationSource : observationSource ,
);
const calibratedPath = buildCalibratedFuturePath ({
observations : calibrationObservationSource ,
times ,
debTemps ,
currentMinutes : hmToMinutes ( detail . local_time ),
reversionMinutes :
hmToMinutes ( detail . forecast ? . sunset ) ?? hmToMinutes ( "18:00" ),
});
const calibratedFuture = calibratedPath . future ;
2026-04-28 11:58:58 +08:00
const mgmPoints = new Array ( times . length ). fill ( null );
if (
! suppressAnkaraMgmObservation &&
detail . mgm ? . temp != null &&
detail . mgm ? . time
) {
const index = findNearestTimeIndex ( times , detail . mgm . time );
const temp = Number ( detail . mgm . temp );
if ( index >= 0 && Number . isFinite ( temp )) {
mgmPoints [ index ] = temp ;
}
}
const mgmHourlyPoints = new Array ( times . length ). fill ( null );
let hasMgmHourly = false ;
const mgmHourlyRows = Array . isArray ( detail . mgm ? . hourly )
? detail . mgm ? . hourly || []
: [];
mgmHourlyRows . forEach (( item ) => {
const index = findNearestTimeIndex ( times , String ( item . time || "" ));
const temp = Number ( item . temp );
if ( index >= 0 && Number . isFinite ( temp )) {
mgmHourlyPoints [ index ] = temp ;
hasMgmHourly = true ;
}
});
const allValues = [
... debTemps . filter (( value ) => value != null ),
2026-05-01 11:11:17 +08:00
... calibratedFuture . filter (( value ) => value != null ),
2026-04-28 11:58:58 +08:00
... metarPoints . filter (( value ) => value != null ),
... airportMetarPoints . filter (( value ) => value != null ),
... mgmPoints . filter (( value ) => value != null ),
... mgmHourlyPoints . filter (( value ) => value != null ),
] as number [];
if ( ! allValues . length ) return null ;
const yScale = getNiceTemperatureScale ( allValues , detail . temp_symbol );
const min = yScale . min ;
const max = yScale . max ;
const yTickStep = yScale . step ;
const tafMarkersRaw = Array . isArray ( detail . taf ? . signal ? . markers )
? detail . taf ? . signal ? . markers || []
: [];
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" ) } ` ;
};
2026-05-01 11:11:17 +08:00
const chartHmToMinutes = ( value : string | null ) => {
2026-04-28 11:58:58 +08:00
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 ;
};
2026-05-01 11:11:17 +08:00
const currentMinutes = chartHmToMinutes ( normalizeHm ( detail . local_time ));
2026-04-28 11:58:58 +08:00
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 ;
const tafMarkerValue = max - 0.4 ;
const tafMarkerPoints = new Array ( times . length ). fill ( null );
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 || "" );
const tafMarkers = tafMarkersRaw
. map (( marker ) => {
const labelTime = String ( marker ? . label_time || "" ). trim ();
const index = findNearestTimeIndex ( times , labelTime );
if ( index >= 0 ) {
tafMarkerPoints [ index ] = tafMarkerValue ;
}
return {
displayType : formatTafMarkerType (
String ( marker ? . marker_type || "" ). trim (),
locale ,
),
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 (),
isCurrent : false ,
isPeakWindow : false ,
suppressionLevel : String ( marker ? . suppression_level || "" ). trim (),
};
})
. filter (( marker ) => marker . index >= 0 );
const currentTafMarker =
currentMinutes !== null
? tafMarkers . find (( marker ) => {
2026-05-01 11:11:17 +08:00
const start = chartHmToMinutes ( normalizeHm ( marker . startLocal ));
const end = chartHmToMinutes ( normalizeHm ( marker . endLocal ));
2026-04-28 11:58:58 +08:00
return start !== null && end !== null && currentMinutes >= start && currentMinutes <= end ;
}) || null
: null ;
const nextTafMarker =
currentMinutes !== null && ! currentTafMarker
? tafMarkers . find (( marker ) => {
2026-05-01 11:11:17 +08:00
const start = chartHmToMinutes ( normalizeHm ( marker . startLocal ));
2026-04-28 11:58:58 +08:00
return start !== null && start > currentMinutes ;
}) || null
: null ;
const peakWindowTafMarker =
peakWindowStartMinutes !== null && peakWindowEndMinutes !== null
? tafMarkers . find (( marker ) => {
2026-05-01 11:11:17 +08:00
const start = chartHmToMinutes ( normalizeHm ( marker . startLocal ));
const end = chartHmToMinutes ( normalizeHm ( marker . endLocal ));
2026-04-28 11:58:58 +08:00
return (
start !== null &&
end !== null &&
start <= peakWindowEndMinutes &&
end >= peakWindowStartMinutes
);
}) || null
: null ;
tafMarkers . forEach (( marker ) => {
const isPrimaryTafMarker =
sameMarker ( marker , currentTafMarker ) || sameMarker ( marker , nextTafMarker );
const isPeakReferenceMarker = sameMarker ( marker , peakWindowTafMarker );
if ( isPrimaryTafMarker ) {
marker . isCurrent = true ;
tafCurrentMarkerPoints [ marker . index ] = tafMarkerValue ;
}
if ( isPeakReferenceMarker ) {
marker . isPeakWindow = true ;
if ( ! isPrimaryTafMarker ) {
tafPeakWindowMarkerPoints [ marker . index ] = tafMarkerValue - 0.15 ;
}
}
});
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 ();
};
const legendParts : string [] = [];
if ( ! suppressAnkaraMgmObservation && detail . mgm ? . temp != null ) {
legendParts . push ( `MGM: ${ detail . mgm . temp }${ detail . temp_symbol } ` );
}
if ( ! hasMgmHourly && debMax != null && omMax != null && Math . abs ( offset ) > 0.3 ) {
const sign = offset > 0 ? "+" : "" ;
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-05-01 11:11:17 +08:00
if ( calibratedPath . adjustmentDelta != null ) {
const sign = calibratedPath . adjustmentDelta > 0 ? "+" : "" ;
legendParts . push (
isEnglish ( locale )
? `METAR-calibrated path applies latest observation bias ${ sign }${ calibratedPath . adjustmentDelta . toFixed ( 1 ) }${ detail . temp_symbol } .`
: `修正路径使用最新 METAR 偏差 ${ sign }${ calibratedPath . adjustmentDelta . toFixed ( 1 ) }${ detail . temp_symbol } 。` ,
);
}
2026-04-28 11:58:58 +08:00
if ( hasMgmHourly ) {
legendParts . push (
isEnglish ( locale )
? "Using MGM hourly forecast to replace DEB curve"
: "已使用 MGM 小时预报替代 DEB 曲线" ,
);
}
if (( detail . trend ? . recent ? . length || 0 ) > 0 || observationSource . length > 0 ) {
const recentData = sortObservationItemsByTime (
observationSource . length > 0
? [... observationSource ]
: [...( detail . trend ? . recent || [])],
);
const recentText = recentData
. slice ( - 4 )
. map (( item ) => ` ${ item . temp }${ detail . temp_symbol } @ ${ item . time } ` )
. join ( " -> " );
legendParts . push ( ` ${ observationDisplayTag } : ${ recentText } ` );
}
if ( airportMetarSource . length > 0 ) {
const airportRecentText = sortObservationItemsByTime ([... airportMetarSource ])
. slice ( - 4 )
. map (( item ) => ` ${ item . temp }${ detail . temp_symbol } @ ${ item . time } ` )
. join ( " -> " );
legendParts . push (
isEnglish ( locale )
? ` ${ metarFallbackTag } : ${ airportRecentText } `
: ` ${ metarFallbackTag } : ${ airportRecentText } ` ,
);
}
if ( detail . metar_status ? . stale_for_today ) {
const dateText = detail . metar_status . last_observation_local_date || "" ;
const tempText =
detail . metar_status . last_temp != null
? ` ${ detail . metar_status . last_temp }${ detail . temp_symbol } `
: "" ;
legendParts . push (
isEnglish ( locale )
? `No same-day ${ metarFallbackTag } report yet; latest report ${ dateText ? ` was ${ dateText } ` : "" }${ tempText ? ` at ${ tempText } ` : "" } .`
: `今日暂无同日 ${ metarFallbackTag } 报文;最近一报 ${ dateText ? `为 ${ dateText } ` : "" }${ tempText ? `, ${ tempText } ` : "" } 。` ,
);
}
if ( shouldUseMetarFallback ) {
legendParts . push (
isEnglish ( locale )
? `Official ${ observationTag } feed is sparse today, so the continuous observation line switches to ${ metarFallbackTag } .`
: `今日官方 ${ observationTag } 点位较稀疏,连续实测线改用 ${ metarFallbackTag } 。` ,
);
}
if ( usingMirrorFallback ) {
legendParts . push (
isEnglish ( locale )
? "Dense observation feed matched the forecast curve exactly, so it was ignored for this chart refresh."
: "本次高密度观测源与预测曲线逐点重合,已忽略该异常源。" ,
);
} else if ( observationCode === "hko" ) {
legendParts . push (
isEnglish ( locale )
? "This city uses HKO official readings. The chart keeps official HKO points instead of switching to airport METAR."
: "该城市按 HKO 官方读数展示;图中保留 HKO 官方点位,不切换到机场 METAR 连续线。" ,
);
} else if ( observationCode === "noaa" ) {
const noaaCode = getNoaaStationCode ( detail );
legendParts . push (
isEnglish ( locale )
? `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 读数结算;图中曲线仅作为结算参考线。` ,
);
}
if ( tafMarkers . length ) {
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 ) } ` ,
);
}
legendParts . push (
isEnglish ( locale )
? "Use the current TAF segment as primary; peak-window segments are reference only."
: "以当前 TAF 时段为准,峰值窗口时段仅作参考。" ,
);
}
const debPastSeries = buildSeriesPoints ( times , debPast );
const debFutureSeries = buildSeriesPoints ( times , debFuture );
2026-05-01 11:11:17 +08:00
const debSeries = buildSeriesPoints ( times , debTemps );
const calibratedFutureSeries = buildSeriesPoints ( times , calibratedFuture );
2026-04-28 11:58:58 +08:00
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 ,
2026-05-01 11:11:17 +08:00
x : chartHmToMinutes ( marker . labelTime ) ?? 0 ,
2026-04-28 11:58:58 +08:00
y : tafMarkerValue ,
}))
. filter (( point ) => point . x > 0 );
const tafPeakWindowMarkerSeries = tafMarkers
. filter (( marker ) => marker . isPeakWindow && ! marker . isCurrent )
. map (( marker ) => ({
marker ,
2026-05-01 11:11:17 +08:00
x : chartHmToMinutes ( marker . labelTime ) ?? 0 ,
2026-04-28 11:58:58 +08:00
y : tafMarkerValue - 0.15 ,
}))
. filter (( point ) => point . x > 0 );
const tafMarkerSeries = tafMarkers
. map (( marker ) => ({
marker ,
2026-05-01 11:11:17 +08:00
x : chartHmToMinutes ( marker . labelTime ) ?? 0 ,
2026-04-28 11:58:58 +08:00
y : tafMarkerValue ,
}))
. filter (( point ) => point . x > 0 );
2026-05-01 11:11:17 +08:00
const xMin = times . length ? chartHmToMinutes ( times [ 0 ]) ?? 0 : 0 ;
const xMax = times . length ? chartHmToMinutes ( times [ times . length - 1 ]) ?? 24 * 60 : 24 * 60 ;
2026-04-28 11:58:58 +08:00
return {
2026-05-10 17:33:15 +08:00
currentIndex ,
2026-04-28 11:58:58 +08:00
datasets : {
airportMetarPoints ,
airportMetarSeries ,
2026-05-01 11:11:17 +08:00
calibratedFuture ,
calibratedFutureSeries ,
calibrationAdjustmentDelta : calibratedPath.adjustmentDelta ,
2026-04-28 11:58:58 +08:00
debFuture ,
debFutureSeries ,
debPast ,
debPastSeries ,
2026-05-01 11:11:17 +08:00
debSeries ,
2026-04-28 11:58:58 +08:00
hasMgmHourly ,
metarPoints ,
metarSeries ,
mgmHourlyPoints ,
mgmHourlySeries ,
mgmPoints ,
mgmSeries ,
offset ,
tafCurrentMarkerPoints ,
tafCurrentMarkerSeries ,
tafMarkerPoints ,
tafMarkerSeries ,
tafPeakWindowMarkerPoints ,
tafPeakWindowMarkerSeries ,
temps ,
tempsSeries ,
},
observationLabel :
observationCode === "noaa" &&
! shouldUseMetarFallback
? isEnglish ( locale )
? ` ${ observationDisplayTag } Settlement Reference`
: ` ${ observationDisplayTag } 结算参考`
: isEnglish ( locale )
? ` ${ observationDisplayTag } Observation`
: ` ${ observationDisplayTag } 实况` ,
legendText : legendParts.join ( " | " ),
max ,
min ,
tafMarkers ,
tickLabels : buildTemperatureTickLabels ( times ),
times ,
xMax ,
xMin ,
yTickStep ,
};
}