2026-04-28 11:58:58 +08:00
import type { CityDetail } from "@/lib/dashboard-types" ;
2026-05-17 14:33:23 +08:00
import { getDisplayAirportPrimary } from "@/lib/airport-observation-display" ;
2026-04-28 11:58:58 +08:00
import type { Locale } from "@/lib/i18n" ;
import {
getNoaaStationCode ,
getObservationSourceCode ,
getObservationSourceTag ,
getRealtimeObservationTag ,
isTurkishMgmCity ,
} from "@/lib/observation-source-utils" ;
import { formatTafMarkerType } from "@/lib/taf-utils" ;
import {
hmToMinutes ,
interpolateSeriesAtMinutes ,
normalizeHm ,
} from "@/lib/time-utils" ;
2026-05-17 20:57:52 +08:00
import {
buildCalibratedPath ,
buildChartTimeAxis ,
buildDebBaselinePath ,
buildObservationGrid ,
buildObservationPointSeries ,
buildSeriesPoints ,
buildTemperatureTickLabels ,
findNearestTimeIndex ,
getNiceTemperatureScale ,
type ChartTimeAxis ,
type DebBaselinePath ,
} from "@/lib/temperature-chart-paths" ;
2026-04-28 11:58:58 +08:00
function isEnglish ( locale : Locale ) {
return locale === "en-US" ;
}
2026-05-01 11:11:17 +08:00
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 } > {
2026-05-17 14:33:23 +08:00
const displayAirportPrimary = getDisplayAirportPrimary ( detail );
2026-04-28 11:58:58 +08:00
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 ,
},
{
2026-05-17 14:33:23 +08:00
sourceLabel : displayAirportPrimary?.source_label || "METAR" ,
temp : displayAirportPrimary?.temp ,
time : displayAirportPrimary?.obs_time || displayAirportPrimary ? . report_time ,
2026-04-28 11:58:58 +08:00
},
{
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 || {};
2026-05-17 19:12:28 +08:00
const mgmHourlyRows = Array . isArray ( detail . mgm ? . hourly )
? detail . mgm ? . hourly || []
: [];
2026-05-17 20:57:52 +08:00
const axis = buildChartTimeAxis (
hourly . times ,
hourly . temps ,
mgmHourlyRows ,
isTurkishMgmCity ( detail ),
);
const { times , temps } = axis ;
2026-04-28 11:58:58 +08:00
if ( ! times . length ) return null ;
2026-05-17 19:12:28 +08:00
const mgmHourlyMax = mgmHourlyRows
. map (( row ) => Number ( row ? . temp ))
. filter (( value ) => Number . isFinite ( value ))
. reduce < number | null >(
( maxValue , value ) => ( maxValue == null ? value : Math.max ( maxValue , value )),
null ,
);
2026-05-17 20:57:52 +08:00
const baseline = buildDebBaselinePath (
times ,
temps ,
detail . deb ? . prediction ,
detail . local_time ,
detail . forecast ? . today_high ,
mgmHourlyMax ,
2026-04-28 11:58:58 +08:00
);
2026-05-17 20:57:52 +08:00
const {
debTemps ,
debPast ,
debFuture ,
currentIndex ,
offset ,
} = baseline ;
const suppressAnkaraMgmObservation = isTurkishMgmCity ( detail );
2026-04-28 11:58:58 +08:00
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 ;
2026-05-17 20:57:52 +08:00
const metarPoints = buildObservationGrid ( observationSource , times );
2026-04-28 11:58:58 +08:00
const airportMetarPoints = new Array ( times . length ). fill ( null );
2026-05-01 11:11:17 +08:00
const calibrationObservationSource = dedupeObservationItems (
metarObservationSource . length ? metarObservationSource : observationSource ,
);
2026-05-17 20:57:52 +08:00
const calibratedPath = buildCalibratedPath (
calibrationObservationSource ,
2026-05-01 11:11:17 +08:00
times ,
debTemps ,
2026-05-17 20:57:52 +08:00
detail . local_time ,
detail . forecast ? . sunset ,
);
2026-05-01 11:11:17 +08: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 ;
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 || []
: [];
2026-05-17 20:57:52 +08:00
const currentMinutes = hmToMinutes ( 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-17 20:57:52 +08:00
const start = hmToMinutes ( normalizeHm ( marker . startLocal ));
const end = hmToMinutes ( 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-17 20:57:52 +08:00
const start = hmToMinutes ( 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-17 20:57:52 +08:00
const start = hmToMinutes ( normalizeHm ( marker . startLocal ));
const end = hmToMinutes ( 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 } ` );
}
2026-05-17 20:57:52 +08:00
if ( ! hasMgmHourly && Math . abs ( offset ) > 0.3 ) {
2026-04-28 11:58:58 +08:00
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 )
2026-05-17 20:57:52 +08:00
? `DEB calibrated path applies latest observation bias ${ sign }${ calibratedPath . adjustmentDelta . toFixed ( 1 ) }${ detail . temp_symbol } .`
: `DEB 修正路径使用最新观测偏差 ${ sign }${ calibratedPath . adjustmentDelta . toFixed ( 1 ) }${ detail . temp_symbol } 。` ,
2026-05-01 11:11:17 +08:00
);
}
2026-04-28 11:58:58 +08:00
if ( hasMgmHourly ) {
2026-05-17 20:57:52 +08:00
const hourly = detail . hourly || {};
const hasPrimaryHourly =
Array . isArray ( hourly . times ) &&
Array . isArray ( hourly . temps ) &&
Math . min ( hourly . times . length , hourly . temps . length ) > 0 ;
const mgmIsForecastBase = ! hasPrimaryHourly && isTurkishMgmCity ( detail );
2026-04-28 11:58:58 +08:00
legendParts . push (
isEnglish ( locale )
2026-05-17 20:57:52 +08:00
? mgmIsForecastBase
2026-05-17 19:12:28 +08:00
? "Using MGM hourly forecast as the DEB curve base"
: "MGM hourly forecast is shown as official hourly guidance"
2026-05-17 20:57:52 +08:00
: mgmIsForecastBase
2026-05-17 19:12:28 +08:00
? "已使用 MGM 小时预报作为 DEB 曲线基底"
: "MGM 小时预报作为官方小时指引显示" ,
2026-04-28 11:58:58 +08:00
);
}
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 );
2026-05-17 20:57:52 +08:00
const metarSeries = buildObservationPointSeries ( observationSource );
const airportMetarSeries = buildObservationPointSeries ( airportMetarSource );
2026-04-28 11:58:58 +08:00
const mgmSeries =
! suppressAnkaraMgmObservation && detail . mgm ? . temp != null && detail . mgm ? . time
2026-05-17 20:57:52 +08:00
? buildObservationPointSeries ([{ time : detail.mgm.time , temp : detail.mgm.temp }])
2026-04-28 11:58:58 +08:00
: [];
const tafCurrentMarkerSeries = tafMarkers
. filter (( marker ) => marker . isCurrent )
. map (( marker ) => ({
marker ,
2026-05-17 20:57:52 +08:00
x : hmToMinutes ( 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-17 20:57:52 +08:00
x : hmToMinutes ( 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-17 20:57:52 +08:00
x : hmToMinutes ( marker . labelTime ) ?? 0 ,
2026-04-28 11:58:58 +08:00
y : tafMarkerValue ,
}))
. filter (( point ) => point . x > 0 );
2026-05-17 20:57:52 +08:00
const xMin = times . length ? hmToMinutes ( times [ 0 ]) ?? 0 : 0 ;
const xMax = times . length ? hmToMinutes ( 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 ,
};
}
2026-05-23 22:59:50 +08:00
export const CHART_TOOLTIP_STYLE : React.CSSProperties = {
background : "#1e293b" ,
border : "1px solid rgba(255,255,255,0.1)" ,
borderRadius : 8 ,
color : "#e2e8f0" ,
fontSize : 12 ,
};