feat: implement LiveTemperatureThresholdChart and add visibility policy unit tests

This commit is contained in:
2569718930@qq.com
2026-05-26 05:46:45 +08:00
parent 8ca9ce4223
commit e1417f607d
3 changed files with 261 additions and 25 deletions
+8 -8
View File
@@ -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 — 仅 METAR10 分钟缓存)
@@ -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;
@@ -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");
}