feat: implement LiveTemperatureThresholdChart and add visibility policy unit tests
This commit is contained in:
@@ -13,6 +13,13 @@
|
||||
| hong kong | HKO 官方 CSV | ~1 min | data.weather.gov.hk, 4 路 CSV |
|
||||
| lau fau shan | HKO 官方 CSV | ~1 min | 同上,站号 LFS |
|
||||
| singapore | MSS 官方 API | ~1 min | api.data.gov.sg, 站号 S24 |
|
||||
| beijing | AMSC AWOS (ZBAA) | ~1 min | 中国 |
|
||||
| shanghai | AMSC AWOS (ZSPD) | ~1 min | 中国 |
|
||||
| guangzhou | AMSC AWOS (ZGGG) | ~1 min | 中国 |
|
||||
| chengdu | AMSC AWOS (ZUUU) | ~1 min | 中国 |
|
||||
| chongqing | AMSC AWOS (ZUCK) | ~1 min | 中国 |
|
||||
| wuhan | AMSC AWOS (ZHHH) | ~1 min | 中国 |
|
||||
| qingdao | AMSC AWOS (ZSQD) | ~1 min | 中国 |
|
||||
|
||||
### Tier 2 — 5 分钟高频 (MADIS)
|
||||
|
||||
@@ -34,13 +41,6 @@
|
||||
|
||||
| 城市 | 来源 | 频率 | 国家/地区 |
|
||||
|------|------|------|------|
|
||||
| beijing | AMSC AWOS (ZBAA) | 准实时 | 中国 |
|
||||
| shanghai | AMSC AWOS (ZSPD) | 准实时 | 中国 |
|
||||
| guangzhou | AMSC AWOS (ZGGG) | 准实时 | 中国 |
|
||||
| chengdu | AMSC AWOS (ZUUU) | 准实时 | 中国 |
|
||||
| chongqing | AMSC AWOS (ZUCK) | 准实时 | 中国 |
|
||||
| wuhan | AMSC AWOS (ZHHH) | 准实时 | 中国 |
|
||||
| qingdao | AMSC AWOS (ZSQD) | 准实时 | 中国 |
|
||||
| tokyo | JMA AMeDAS (44166) | 10 min | 日本 |
|
||||
| ankara | MGM (17128) | 5-15 min | 土耳其 |
|
||||
| istanbul | MGM (17058) | 5-15 min | 土耳其 |
|
||||
@@ -48,7 +48,6 @@
|
||||
| amsterdam | KNMI 数据平台 | 10 min | 荷兰 |
|
||||
| taipei | CWA 开放数据 (466920) | ~10 min | 台湾 |
|
||||
| tel aviv | IMS Lod (225) | 实时 | 以色列 |
|
||||
| jeddah | NCM 官方 | 实时 | 沙特 |
|
||||
| paris | AEROWEB 实况 / AROME HD 15min | 实时/15min | 法国 |
|
||||
|
||||
### Tier 4 — 仅 METAR(10 分钟缓存)
|
||||
@@ -56,6 +55,7 @@
|
||||
| 城市 | ICAO | 备注 |
|
||||
|------|------|------|
|
||||
| london | EGLC | Met Office 仅 1 小时更新 |
|
||||
| jeddah | OEJN | NCM 数据源目前不可用 |
|
||||
| moscow | UUWW | 俄罗斯 METAR 集群 + NOAA WRH 结算 |
|
||||
| shenzhen | ZGSZ | 唯一无 AMSC AWOS 的中国城市 |
|
||||
| munich | EDDM | DWD 延迟约 1 小时 |
|
||||
|
||||
@@ -67,6 +67,19 @@ function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function getVisibleTemperatureSeries(
|
||||
city: string,
|
||||
series: EvidenceSeries[],
|
||||
userToggledKeys: Record<string, boolean>,
|
||||
) {
|
||||
return series.filter((item) => {
|
||||
if (userToggledKeys[item.key] !== undefined) {
|
||||
return userToggledKeys[item.key];
|
||||
}
|
||||
return isTemperatureSeriesVisibleByDefault(city, item.key);
|
||||
});
|
||||
}
|
||||
|
||||
function buildRunwayPlates(
|
||||
amos: AmosData | null | undefined,
|
||||
row: ScanOpportunityRow | null,
|
||||
@@ -152,6 +165,7 @@ function buildRunwayPlates(
|
||||
}
|
||||
|
||||
type ObsPoint = { time?: string | null; temp?: number | null };
|
||||
type RawObsPoint = ObsPoint | [string | number | null, number | null | undefined];
|
||||
|
||||
type EvidenceSeries = {
|
||||
key: string;
|
||||
@@ -253,8 +267,17 @@ function formatTimestamp(ts: number): string {
|
||||
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function normObs(points: ObsPoint[] | null | undefined, tzOffsetSeconds: number, limit = MAX_OBS_POINTS) {
|
||||
function normalizeRawObsPoint(point: RawObsPoint): ObsPoint | null {
|
||||
if (Array.isArray(point)) {
|
||||
return { time: point[0] == null ? null : String(point[0]), temp: validNumber(point[1]) };
|
||||
}
|
||||
return point;
|
||||
}
|
||||
|
||||
function normObs(points: RawObsPoint[] | null | undefined, tzOffsetSeconds: number, limit = MAX_OBS_POINTS) {
|
||||
return (points || [])
|
||||
.map(normalizeRawObsPoint)
|
||||
.filter((p): p is ObsPoint => p !== null)
|
||||
.filter((p) => validNumber(p.temp) !== null && p.time)
|
||||
.map((p) => ({
|
||||
ts: getCityLocalUtcTimestamp(p.time, tzOffsetSeconds)!,
|
||||
@@ -273,6 +296,63 @@ function seriesStats(values: Array<number | null>) {
|
||||
return { latest, high, delta15 };
|
||||
}
|
||||
|
||||
function latestObservationValue(obs: Array<{ ts: number; value: number }>) {
|
||||
if (!obs.length) return null;
|
||||
return obs.reduce((latest, point) => (point.ts > latest.ts ? point : latest), obs[0]).value;
|
||||
}
|
||||
|
||||
function maxObservationValue(obs: Array<{ ts: number; value: number }>) {
|
||||
if (!obs.length) return null;
|
||||
return Math.max(...obs.map((point) => point.value));
|
||||
}
|
||||
|
||||
function observationSetContains(
|
||||
superset: Array<{ ts: number; value: number }>,
|
||||
subset: Array<{ ts: number; value: number }>,
|
||||
) {
|
||||
if (!superset.length || !subset.length) return false;
|
||||
return subset.every((point) =>
|
||||
superset.some((candidate) => candidate.ts === point.ts && Math.abs(candidate.value - point.value) < 0.01),
|
||||
);
|
||||
}
|
||||
|
||||
function getObservationDisplayMetrics(
|
||||
row: ScanOpportunityRow | null,
|
||||
hourly: HourlyForecast,
|
||||
settlementPlate?: { maxTemp: number | null } | null,
|
||||
) {
|
||||
const tzOffset = row?.tz_offset_seconds ?? 0;
|
||||
const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset);
|
||||
const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset);
|
||||
const latestSettlement = latestObservationValue(settlementObs);
|
||||
const latestMetar = latestObservationValue(metarObs);
|
||||
const highSettlement = maxObservationValue(settlementObs);
|
||||
const highMetar = maxObservationValue(metarObs);
|
||||
const airportCurrentTemp = validNumber(hourly?.airportCurrent?.temp) ?? validNumber(hourly?.airportPrimary?.temp);
|
||||
const airportHigh = validNumber(hourly?.airportCurrent?.max_so_far) ?? validNumber(hourly?.airportPrimary?.max_so_far);
|
||||
const rowMetarHigh = validNumber(row?.metar_context?.airport_max_so_far ?? row?.metar_context?.max_temp ?? row?.current_max_so_far);
|
||||
|
||||
const currentRunwayTemp =
|
||||
validNumber(hourly?.amos?.temp_c) ??
|
||||
settlementPlate?.maxTemp ??
|
||||
latestSettlement ??
|
||||
latestMetar ??
|
||||
airportCurrentTemp ??
|
||||
validNumber(row?.current_temp) ??
|
||||
null;
|
||||
const observedHighMetar = airportHigh ?? highSettlement ?? highMetar ?? rowMetarHigh ?? null;
|
||||
const observedHighRunway =
|
||||
settlementPlate?.maxTemp ??
|
||||
highSettlement ??
|
||||
airportHigh ??
|
||||
highMetar ??
|
||||
validNumber(row?.current_max_so_far) ??
|
||||
currentRunwayTemp ??
|
||||
null;
|
||||
|
||||
return { currentRunwayTemp, observedHighMetar, observedHighRunway };
|
||||
}
|
||||
|
||||
function isSettlementRunway(row: ScanOpportunityRow | null, rwy: string) {
|
||||
const cityKey = normalizeCityKey(row?.city);
|
||||
const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || [];
|
||||
@@ -307,6 +387,7 @@ type HourlyForecast = {
|
||||
multiModelDaily?: Record<string, DailyModelForecast>;
|
||||
settlementTodayObs?: ObsPoint[];
|
||||
metarTodayObs?: ObsPoint[];
|
||||
airportPrimaryTodayObs?: RawObsPoint[];
|
||||
} | null;
|
||||
|
||||
function parseRunwayHistoryValue(point: Record<string, unknown>) {
|
||||
@@ -368,6 +449,7 @@ function buildRunwayHistorySeries(
|
||||
const runwayObs = amos?.runway_obs;
|
||||
const runwayPairs = runwayObs?.runway_pairs || [];
|
||||
const runwayTemps = runwayObs?.temperatures || [];
|
||||
const pointTemps = runwayObs?.point_temperatures || [];
|
||||
const anchor =
|
||||
getCityLocalUtcTimestamp(amos?.observation_time_local || amos?.observation_time || hourly?.localTime || row?.local_time, tzOffset, localDateStr) ??
|
||||
getCityLocalUtcTimestamp(row?.local_time, tzOffset, localDateStr);
|
||||
@@ -376,20 +458,35 @@ function buildRunwayHistorySeries(
|
||||
|
||||
return runwayTemps
|
||||
.map((rawTemps, index) => {
|
||||
if (!Array.isArray(rawTemps) || rawTemps.length <= 2) return null;
|
||||
if (!Array.isArray(rawTemps)) return null;
|
||||
const rwy = runwayLabelFromPair(runwayPairs[index], index);
|
||||
const isSettlement = isSettlementRunway(row, rwy);
|
||||
const values = rawTemps
|
||||
.map(validNumber)
|
||||
const pointTemp = Array.isArray(pointTemps) ? pointTemps[index] : null;
|
||||
const snapshotValues = [
|
||||
validNumber((pointTemp as any)?.tdz_temp),
|
||||
validNumber((pointTemp as any)?.mid_temp),
|
||||
validNumber((pointTemp as any)?.end_temp),
|
||||
validNumber((pointTemp as any)?.target_runway_max),
|
||||
].filter((value): value is number => value !== null);
|
||||
const samples = rawTemps.map(validNumber).filter((value): value is number => value !== null);
|
||||
const valuesForLine = samples.length > 1
|
||||
? samples
|
||||
: snapshotValues.length > 1
|
||||
? snapshotValues
|
||||
: samples.length === 1
|
||||
? [samples[0], samples[0]]
|
||||
: snapshotValues.length === 1
|
||||
? [snapshotValues[0], snapshotValues[0]]
|
||||
: [];
|
||||
const values = valuesForLine
|
||||
.map((value, pointIndex) => {
|
||||
if (value === null) return null;
|
||||
const minutesFromEnd = rawTemps.length - 1 - pointIndex;
|
||||
const minutesFromEnd = (valuesForLine.length - 1 - pointIndex) * FULL_DAY_SLOT_MINUTES;
|
||||
return {
|
||||
ts: anchor - minutesFromEnd * 60 * 1000,
|
||||
value,
|
||||
};
|
||||
})
|
||||
.filter((point): point is { ts: number; value: number } => point !== null);
|
||||
.filter((point) => validNumber(point.value) !== null);
|
||||
if (values.length <= 1) return null;
|
||||
return {
|
||||
key: runwaySeriesKey(rwy),
|
||||
@@ -625,6 +722,7 @@ function buildFullDayChartData(
|
||||
|
||||
const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset);
|
||||
const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset);
|
||||
const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset);
|
||||
const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr);
|
||||
|
||||
const slots = generateFullDaySlots(localDateStr);
|
||||
@@ -666,7 +764,21 @@ function buildFullDayChartData(
|
||||
}
|
||||
|
||||
// ── METAR ──
|
||||
if (metarObs.length) {
|
||||
if (madisObs.length) {
|
||||
const madisVals = binObservationsToSlots(slots, madisObs);
|
||||
if (madisVals.some((v) => v !== null)) {
|
||||
series.push({
|
||||
key: "madis",
|
||||
label: hourly?.airportPrimary?.source_label || "NOAA MADIS",
|
||||
source: hourly?.airportPrimary?.station_code || row?.airport || "MADIS",
|
||||
color: "#0284c7",
|
||||
dashed: false,
|
||||
values: madisVals,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (metarObs.length && !observationSetContains(madisObs, metarObs)) {
|
||||
const mvals = binObservationsToSlots(slots, metarObs);
|
||||
if (mvals.some((v) => v !== null)) {
|
||||
series.push({
|
||||
@@ -908,6 +1020,7 @@ export function LiveTemperatureThresholdChart({
|
||||
multiModelDaily: json.multi_model_daily || {},
|
||||
settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined,
|
||||
metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined,
|
||||
airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined,
|
||||
};
|
||||
_hourlyCache.set(city, { ts: Date.now(), data });
|
||||
setHourly(data);
|
||||
@@ -951,7 +1064,7 @@ export function LiveTemperatureThresholdChart({
|
||||
};
|
||||
|
||||
const activeSeries = useMemo(() => {
|
||||
return chartSeries.filter((s) => isSeriesVisible(s.key));
|
||||
return getVisibleTemperatureSeries(city, chartSeries, userToggledKeys);
|
||||
}, [chartSeries, userToggledKeys, city]);
|
||||
|
||||
const cityKey = String(row?.city || "").toLowerCase().trim();
|
||||
@@ -987,9 +1100,10 @@ export function LiveTemperatureThresholdChart({
|
||||
const metarHighLabel = isHKO ? '天文台'
|
||||
: 'METAR 官方';
|
||||
|
||||
const currentRunwayTemp = validNumber(hourly?.amos?.temp_c) ?? validNumber(row?.current_temp) ?? settlementPlate?.maxTemp ?? null;
|
||||
const observedHighMetar = validNumber(row?.metar_context?.airport_max_so_far ?? row?.metar_context?.max_temp ?? row?.current_max_so_far) ?? null;
|
||||
const observedHighRunway = validNumber(row?.current_max_so_far) ?? settlementPlate?.maxTemp ?? currentRunwayTemp ?? null;
|
||||
const { currentRunwayTemp, observedHighMetar, observedHighRunway } = useMemo(
|
||||
() => getObservationDisplayMetrics(row, hourly, settlementPlate),
|
||||
[row, hourly, settlementPlate],
|
||||
);
|
||||
const wundergroundDailyHigh = validNumber(hourly?.airportCurrent?.max_so_far ?? hourly?.airportPrimary?.max_so_far) ?? null;
|
||||
|
||||
const modelValues = Object.values(row?.model_cluster_sources || {})
|
||||
@@ -1041,10 +1155,10 @@ export function LiveTemperatureThresholdChart({
|
||||
return list.sort((a, b) => a.threshold - b.threshold);
|
||||
}, [row, allRows]);
|
||||
|
||||
const intDegreeTicks = useMemo(() => buildIntDegreeTicks(series, data), [series, data]);
|
||||
const intDegreeTicks = useMemo(() => buildIntDegreeTicks(activeSeries, data), [activeSeries, data]);
|
||||
const chartDomain = useMemo(
|
||||
() => buildChartDomain(series, data),
|
||||
[series, data],
|
||||
() => buildChartDomain(activeSeries, data),
|
||||
[activeSeries, data],
|
||||
);
|
||||
|
||||
const subtitle = row
|
||||
@@ -1328,13 +1442,13 @@ export function LiveTemperatureThresholdChart({
|
||||
)}
|
||||
|
||||
{/* Multi-model list (Only in 1D mode and when not compact) */}
|
||||
{timeframe === "1D" && !compact && hasRunwayData && series.some((s) => s.key.startsWith("model_curve_")) && (
|
||||
{timeframe === "1D" && !compact && hasRunwayData && activeSeries.some((s) => s.key.startsWith("model_curve_")) && (
|
||||
<div className="shrink-0 border-b border-slate-200 bg-white px-4 py-2">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[11px]">
|
||||
<span className="font-black text-slate-500 uppercase mr-2">
|
||||
{isEn ? "Models:" : "多模型:"}
|
||||
</span>
|
||||
{series
|
||||
{activeSeries
|
||||
.filter((s) => s.key.startsWith("model_curve_"))
|
||||
.map((s) => {
|
||||
const stats = seriesStats(s.values);
|
||||
@@ -1467,3 +1581,5 @@ export function __buildTemperatureChartDataForTest(
|
||||
}
|
||||
|
||||
export const __isTemperatureSeriesVisibleByDefaultForTest = isTemperatureSeriesVisibleByDefault;
|
||||
export const __getVisibleTemperatureSeriesForTest = getVisibleTemperatureSeries;
|
||||
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
|
||||
|
||||
+120
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
__buildTemperatureChartDataForTest,
|
||||
__getObservationDisplayMetricsForTest,
|
||||
__getVisibleTemperatureSeriesForTest,
|
||||
__isTemperatureSeriesVisibleByDefaultForTest,
|
||||
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||
|
||||
@@ -50,6 +52,7 @@ export function runTests() {
|
||||
} as any;
|
||||
|
||||
const { series } = __buildTemperatureChartDataForTest(guangzhou, hourly, "1D");
|
||||
const defaultVisibleSeries = __getVisibleTemperatureSeriesForTest("guangzhou", series, {});
|
||||
|
||||
const settlementRunway = seriesByKey(series, "runway_02L_20R") as any;
|
||||
assert(settlementRunway, "settlement runway should use a stable runway-pair key");
|
||||
@@ -82,6 +85,16 @@ export function runTests() {
|
||||
!__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "model_curve_ECMWF"),
|
||||
"multi-model curves should be hidden by default",
|
||||
);
|
||||
assert(
|
||||
!defaultVisibleSeries.some((item) => item.key === "model_curve_ECMWF"),
|
||||
"hidden multi-model curves should not affect the active chart series by default",
|
||||
);
|
||||
assert(
|
||||
__getVisibleTemperatureSeriesForTest("guangzhou", series, { model_curve_ECMWF: true }).some(
|
||||
(item) => item.key === "model_curve_ECMWF",
|
||||
),
|
||||
"users should still be able to enable a hidden multi-model curve from the legend",
|
||||
);
|
||||
assert(
|
||||
__isTemperatureSeriesVisibleByDefaultForTest("paris", "model_curve_AROME HD"),
|
||||
"Paris AROME HD should be the only default-visible model curve exception",
|
||||
@@ -107,4 +120,111 @@ export function runTests() {
|
||||
);
|
||||
assert(seriesByKey(shenzhen.series, "metar"), "Shenzhen/Lau Fau Shan observations should stay as METAR/HKO observations, not runway data");
|
||||
assert(!shenzhen.series.some((item) => item.key.startsWith("runway_")), "Shenzhen should not be treated as an AMSC runway city");
|
||||
|
||||
const chengduFromAmosSnapshot = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "chengdu",
|
||||
local_date: "2026-05-26",
|
||||
local_time: "05:25",
|
||||
tz_offset_seconds: 8 * 60 * 60,
|
||||
airport: "ZUUU",
|
||||
} as any,
|
||||
{
|
||||
localTime: "05:25",
|
||||
times: ["00:00", "06:00", "12:00", "18:00"],
|
||||
temps: [24, 28, 31, 27],
|
||||
amos: {
|
||||
observation_time: "2026-05-25T21:25:00+00:00",
|
||||
observation_time_local: "2026-05-26 05:25:00",
|
||||
runway_obs: {
|
||||
runway_pairs: [
|
||||
["02L", "20R"],
|
||||
["02R", "20L"],
|
||||
],
|
||||
temperatures: [
|
||||
[24.4, null],
|
||||
[24.2, null],
|
||||
],
|
||||
point_temperatures: [
|
||||
{ runway: "02L/20R", tdz_temp: 24.4, mid_temp: null, end_temp: 24.8 },
|
||||
{ runway: "02R/20L", tdz_temp: 24.2, mid_temp: null, end_temp: 24.6 },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
"1D",
|
||||
);
|
||||
|
||||
const chengduSettlementRunway = seriesByKey(chengduFromAmosSnapshot.series, "runway_02L_20R") as any;
|
||||
assert(chengduSettlementRunway, "AMOS runway_obs snapshot should still create the settlement runway chart line");
|
||||
assert(chengduSettlementRunway.color === "#009688", "AMOS snapshot settlement runway should use highlight cyan");
|
||||
assert(chengduSettlementRunway.featured === true, "AMOS snapshot settlement runway should be featured");
|
||||
assert(!chengduSettlementRunway.dashed, "AMOS snapshot settlement runway should be solid");
|
||||
|
||||
const chengduAuxRunway = seriesByKey(chengduFromAmosSnapshot.series, "runway_02R_20L") as any;
|
||||
assert(chengduAuxRunway, "AMOS runway_obs snapshot should create auxiliary runway chart lines");
|
||||
assert(chengduAuxRunway.dashed === true, "AMOS snapshot auxiliary runway should be dashed");
|
||||
|
||||
const newYorkMetrics = __getObservationDisplayMetricsForTest(
|
||||
{
|
||||
city: "new york",
|
||||
local_date: "2026-05-25",
|
||||
local_time: "17:30",
|
||||
tz_offset_seconds: -4 * 60 * 60,
|
||||
current_temp: 0,
|
||||
current_max_so_far: 0,
|
||||
metar_context: {
|
||||
airport_max_so_far: 0,
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
localTime: "17:30",
|
||||
times: ["00:00"],
|
||||
temps: [55],
|
||||
airportCurrent: {
|
||||
temp: 73.9,
|
||||
max_so_far: 73.9,
|
||||
},
|
||||
metarTodayObs: [
|
||||
{ time: "16:51", temp: 73.9 },
|
||||
{ time: "15:51", temp: 73.0 },
|
||||
{ time: "00:34", temp: 55.0 },
|
||||
],
|
||||
} as any,
|
||||
null,
|
||||
);
|
||||
|
||||
assert(newYorkMetrics.currentRunwayTemp === 73.9, "weather-station header should use detail METAR/current temp before stale row zero");
|
||||
assert(newYorkMetrics.observedHighMetar === 73.9, "METAR high header should use detail METAR high before stale row zero");
|
||||
|
||||
const newYorkWithMadis = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "new york",
|
||||
local_date: "2026-05-25",
|
||||
local_time: "17:30",
|
||||
tz_offset_seconds: -4 * 60 * 60,
|
||||
airport: "KLGA",
|
||||
} as any,
|
||||
{
|
||||
localTime: "17:30",
|
||||
times: ["00:00", "06:00", "12:00", "18:00"],
|
||||
temps: [55, 57, 65, 72],
|
||||
airportPrimary: {
|
||||
source_code: "madis_hfmetar",
|
||||
source_label: "NOAA MADIS",
|
||||
},
|
||||
airportPrimaryTodayObs: [
|
||||
["16:51", 73.9],
|
||||
["15:51", 73],
|
||||
["15:47", 71.6],
|
||||
["15:44", 72],
|
||||
],
|
||||
metarTodayObs: [{ time: "16:51", temp: 73.9 }],
|
||||
} as any,
|
||||
"1D",
|
||||
);
|
||||
const madisSeries = seriesByKey(newYorkWithMadis.series, "madis") as any;
|
||||
assert(madisSeries, "US MADIS airport-primary observations should render as a dedicated chart series");
|
||||
assert(madisSeries.label.includes("MADIS"), "US MADIS series should be labeled as NOAA MADIS instead of plain METAR");
|
||||
assert(madisSeries.values.filter((value: number | null) => value !== null).length >= 2, "MADIS series should keep sub-hourly observations");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user