feat: implement live temperature threshold charting component with SSE patch support and data collection logic

This commit is contained in:
2569718930@qq.com
2026-05-27 08:42:08 +08:00
parent 65fe2d7361
commit bbd7c768f8
9 changed files with 380 additions and 77 deletions
@@ -117,7 +117,7 @@ export function LiveTemperatureThresholdChart({
const latestPatch = useLatestPatch(city);
const resyncVersion = useSseResyncVersion();
const timeframe = "1D";
const [viewMode, setViewMode] = useState<"auto" | "full">("auto");
const [viewMode, setViewMode] = useState<"auto" | "full">("full");
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
const [liveTemp, setLiveTemp] = useState<number | null>(null);
const [isHourlyLoading, setIsHourlyLoading] = useState(false);
@@ -153,7 +153,8 @@ export function runTests() {
"SSE replay resync should refresh full detail in the background without showing the loading overlay",
);
assert(chart.includes("viewMode"), "temperature chart must expose a view mode for DEB-peak auto view versus full-day view");
assert(chart.includes("getDebPeakWindowRange"), "temperature chart must derive its default view from the DEB peak window");
assert(chart.includes('useState<"auto" | "full">("full")'), "temperature chart must default every city panel to the all-day view");
assert(chart.includes("getDebPeakWindowRange"), "temperature chart must still derive the optional Peak view from the DEB peak window");
assert(
chart.includes('isEn ? "Peak" : "高温"') && chart.includes('isEn ? "All Day" : "全天"'),
"temperature chart view-mode labels must translate 高温/全天 as Peak/All Day",
@@ -73,6 +73,32 @@ export function runTests() {
] as any).state === "watch",
"US Fahrenheit charts should convert Celsius thresholds against observed highs before deciding peak glow state",
);
assert(
__getPeakGlowStateForTest({ temp_symbol: "°C", current_max_so_far: 25.0 } as any, [
{ ts: Date.UTC(2026, 4, 27, 0, 0), hourly_forecast: 22.0, runway: 25.0 },
{ ts: Date.UTC(2026, 4, 27, 4, 0), hourly_forecast: 21.6, runway: 24.4 },
{ ts: Date.UTC(2026, 4, 27, 8, 12), hourly_forecast: 22.1, runway: 25.0 },
{ ts: Date.UTC(2026, 4, 27, 12, 0), hourly_forecast: 26.8, runway: null },
{ ts: Date.UTC(2026, 4, 27, 15, 0), hourly_forecast: 28.0, runway: null },
{ ts: Date.UTC(2026, 4, 27, 18, 0), hourly_forecast: 25.0, runway: null },
] as any, [
{
key: "runway_20R_02L",
label: "20R/02L",
source: "Runway",
color: "#009688",
values: [25.0, 24.4, 25.0, null, null, null],
},
{
key: "hourly_forecast",
label: "DEB Forecast",
source: "DEB Hourly",
color: "#f97316",
values: [22.0, 21.6, 22.1, 26.8, 28.0, 25.0],
},
] as any).state === "none",
"morning observations near the intraday observed high should not trigger peak glow before the forecast hot window",
);
const guangzhou = {
city: "guangzhou",
@@ -452,6 +478,122 @@ export function runTests() {
"empty runwayPlateHistory should fall back to AMOS runway_obs so runway cities still draw runway curves",
);
const busanWithRunwayHistory = __buildTemperatureChartDataForTest(
{
city: "busan",
local_date: "2026-05-27",
local_time: "08:20",
tz_offset_seconds: 9 * 60 * 60,
temp_symbol: "°C",
} as any,
{
localTime: "08:20",
times: ["00:00", "12:00", "18:00", "23:00"],
temps: [19.6, 21.1, 20.0, 19.0],
airportPrimary: {
source_code: "amos",
source_label: "AMOS",
temp: 21.0,
obs_time: "2026-05-26T23:20:00Z",
},
airportPrimaryTodayObs: [
["2026-05-26T23:19:00Z", 21.0],
["2026-05-26T23:20:00Z", 21.0],
],
runwayPlateHistory: {
"SR/SL": [
{ time: "2026-05-26T23:19:00Z", temp: 20.9 },
{ time: "2026-05-26T23:20:00Z", temp: 21.1 },
],
},
} as any,
"1D",
);
assert(
!seriesByKey(busanWithRunwayHistory.series, "madis"),
"Busan should not render the AMOS aggregate airport-primary series when runway sensor data is available",
);
const busanRunway = seriesByKey(busanWithRunwayHistory.series, runwayKey("SR/SL")) as any;
assert(busanRunway, "Busan SR/SL runway history should render as the runway curve");
assert(busanRunway.featured === true, "Busan SR/SL should be treated as the settlement runway");
assert(busanRunway.label.includes("结算跑道"), "Busan SR/SL should be labeled as the settlement runway");
const busanMergedHourly = __mergePatchIntoHourlyForTest(
{
localTime: "08:19",
times: ["00:00", "12:00", "18:00", "23:00"],
temps: [19.6, 21.1, 20.0, 19.0],
runwayPlateHistory: {
"SR/SL": [{ time: "2026-05-26T23:19:00Z", temp: 20.9 }],
},
} as any,
{
type: "city_observation_patch.v1",
city: "busan",
revision: 21,
changes: {
temp: 21.1,
obs_time: "2026-05-26T23:20:00Z",
source: "amos",
amos: {
source: "amos",
icao: "RKPK",
runway_obs: {
runway_pairs: [["S R", "S L"]],
temperatures: [[21.1, 12.4]],
},
},
},
} as any,
);
const busanMergedChart = __buildTemperatureChartDataForTest(
{
city: "busan",
local_date: "2026-05-27",
local_time: "08:20",
tz_offset_seconds: 9 * 60 * 60,
temp_symbol: "°C",
} as any,
busanMergedHourly as any,
"1D",
);
const busanMergedRunway = seriesByKey(busanMergedChart.series, runwayKey("SR/SL")) as any;
assert(busanMergedRunway, "AMOS runway_obs patch should append Busan SR/SL into runway history");
assert(
busanMergedRunway.values.some((value: number | null) => value === 21.1),
"AMOS runway_obs patch should use the runway temperature, not ignore the SR/SL point",
);
const busanCurrentOnly = __buildTemperatureChartDataForTest(
{
city: "busan",
local_date: "2026-05-27",
local_time: "08:20",
tz_offset_seconds: 9 * 60 * 60,
temp_symbol: "°C",
} as any,
{
localTime: "08:20",
times: ["00:00", "12:00", "18:00", "23:00"],
temps: [19.6, 21.1, 20.0, 19.0],
amos: {
source: "amos",
observation_time: "2026-05-26T23:20:00Z",
runway_obs: {
runway_pairs: [["S R", "S L"]],
temperatures: [[21.1, 12.4]],
},
},
} as any,
"1D",
);
const busanCurrentRunway = seriesByKey(busanCurrentOnly.series, runwayKey("SR/SL")) as any;
const busanCurrentValues = (busanCurrentRunway?.values || []).filter((value: number | null) => value !== null);
assert(
!busanCurrentValues.includes(12.4),
"AMOS temp/dew tuples should not be misread as two runway temperature samples",
);
const newYorkMetrics = __getObservationDisplayMetricsForTest(
{
city: "new york",
@@ -22,6 +22,7 @@ const SETTLEMENT_RUNWAY_PAIRS: Record<string, Array<[string, string]>> = {
chongqing: [["20R", "02L"]],
wuhan: [["04", "22"]],
seoul: [["15R", "33L"]],
busan: [["SR", "SL"]],
};
function normalizeRunwayLabel(value?: string | null) {
@@ -117,18 +118,22 @@ function buildRunwayPlates(
if (!Array.isArray(pair) || pair.length < 2) return;
const isSettlement = settlementKeys.has(pairKey(pair));
const tdz = validNumber(pointTemps[index]?.tdz_temp);
const mid = validNumber(pointTemps[index]?.mid_temp);
const end = validNumber(pointTemps[index]?.end_temp);
const pointTemp = pointTemps[index] as any;
const aggregateRunwayTemp = validNumber(pointTemp?.temp) ?? validNumber(pointTemp?.target_runway_max);
const tdz = validNumber(pointTemp?.tdz_temp);
const mid = validNumber(pointTemp?.mid_temp);
const end = validNumber(pointTemp?.end_temp);
const isAmosTempDewTuple = String(amos.source || "").toLowerCase() === "amos";
const historyVals = Array.isArray(runwayTemps[index])
const historyVals = !isAmosTempDewTuple && Array.isArray(runwayTemps[index])
? (runwayTemps[index] as Array<number | null>).map(validNumber).filter((v): v is number => v !== null)
: [];
const aggregateVal = aggregateRunwayTemp !== null ? [aggregateRunwayTemp] : [];
const tdzVal = tdz !== null ? [tdz] : [];
const midVal = mid !== null ? [mid] : [];
const endVal = end !== null ? [end] : [];
const allVals = [...historyVals, ...tdzVal, ...midVal, ...endVal];
const allVals = [...historyVals, ...aggregateVal, ...tdzVal, ...midVal, ...endVal];
const maxTemp = allVals.length ? Math.max(...allVals) : null;
const dailyHigh = historyVals.length ? Math.max(...historyVals) : maxTemp;
@@ -606,6 +611,37 @@ function runwayLabelFromPair(rawPair: unknown, index: number) {
return `RWY ${index + 1}`;
}
function runwayTemperatureFromPairTuple(rawTemp: unknown) {
if (Array.isArray(rawTemp)) return validNumber(rawTemp[0]);
return validNumber(rawTemp);
}
function runwayPatchPointsFromRunwayObs(runwayObs: any) {
const directPoints = Array.isArray(runwayObs?.point_temperatures)
? runwayObs.point_temperatures
: [];
if (directPoints.length) return directPoints;
const runwayPairs = Array.isArray(runwayObs?.runway_pairs)
? runwayObs.runway_pairs
: [];
const temperatures = Array.isArray(runwayObs?.temperatures)
? runwayObs.temperatures
: [];
return runwayPairs
.map((pair: unknown, index: number) => {
const temp = runwayTemperatureFromPairTuple(temperatures[index]);
if (temp === null) return null;
return {
runway: runwayLabelFromPair(pair, index),
temp,
target_runway_max: temp,
};
})
.filter((point: any): point is { runway: string; temp: number; target_runway_max: number } => point !== null);
}
type HourlyForecast = {
forecastTodayHigh?: number | null;
debPrediction?: number | null;
@@ -874,8 +910,8 @@ function mergePatchIntoHourly(
const runwayObs = amosChanges?.runway_obs;
const runwayPoints = Array.isArray(changes.runway_points)
? changes.runway_points
: runwayObs && Array.isArray(runwayObs.point_temperatures)
? runwayObs.point_temperatures
: runwayObs
? runwayPatchPointsFromRunwayObs(runwayObs)
: [];
if (runwayPoints.length && obsTimeVal) {
const history: Record<string, Array<Record<string, unknown>>> = {};
@@ -1014,6 +1050,7 @@ function buildRunwayHistorySeries(
const runwayPairs = runwayObs?.runway_pairs || [];
const runwayTemps = runwayObs?.temperatures || [];
const pointTemps = runwayObs?.point_temperatures || [];
const isAmosTempDewTuple = String(amos?.source || "").toLowerCase() === "amos";
const anchor =
getCityLocalUtcTimestamp(amos?.observation_time_local || amos?.observation_time || hourly?.localTime || row?.local_time, tzOffset, localDateStr) ??
getCityLocalUtcTimestamp(row?.local_time, tzOffset, localDateStr);
@@ -1025,14 +1062,20 @@ function buildRunwayHistorySeries(
if (!Array.isArray(rawTemps)) return null;
const rwy = runwayLabelFromPair(runwayPairs[index], index);
const isSettlement = isSettlementRunway(row, rwy);
const pointTemp = Array.isArray(pointTemps) ? pointTemps[index] : null;
const pointTemp = Array.isArray(pointTemps) ? (pointTemps[index] as any) : null;
const aggregateRunwayTemp =
validNumber(pointTemp?.temp) ??
validNumber(pointTemp?.target_runway_max) ??
(isAmosTempDewTuple ? runwayTemperatureFromPairTuple(rawTemps) : 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),
aggregateRunwayTemp,
validNumber(pointTemp?.tdz_temp),
validNumber(pointTemp?.mid_temp),
validNumber(pointTemp?.end_temp),
].filter((value): value is number => value !== null);
const samples = rawTemps.map(validNumber).filter((value): value is number => value !== null);
const samples = isAmosTempDewTuple
? []
: rawTemps.map(validNumber).filter((value): value is number => value !== null);
const valuesForLine = samples.length > 1
? samples
: snapshotValues.length > 1
@@ -1336,13 +1379,26 @@ function buildFullDayChartData(
const isAmscSource =
(hourly?.airportPrimary as any)?.source === "amsc_awos" ||
String(hourly?.airportPrimary?.source_label || "").toLowerCase().includes("amsc");
const isKoreanAmosSource =
(settlementCityKey === "seoul" || settlementCityKey === "busan") &&
(
String(
(hourly?.airportPrimary as any)?.source ||
hourly?.airportPrimary?.source_code ||
hourly?.airportPrimary?.source_label ||
hourly?.amos?.source ||
"",
).toLowerCase().includes("amos") ||
Boolean(hourly?.amos?.runway_obs)
);
const isRunwaySensorAggregateSource = isAmscSource || isKoreanAmosSource;
const shouldRenderMetar = metarObs.length > 0 && !observationSetContains(finalMadisObs, metarObs);
const timelineSet = new Set<number>();
runwayHistorySeries.forEach((rhs) => rhs.points.forEach((point) => timelineSet.add(point.ts)));
normBandObs.forEach((point) => timelineSet.add(point.ts));
finalSettlementObs.forEach((point) => timelineSet.add(point.ts));
if (!isAmscSource) finalMadisObs.forEach((point) => timelineSet.add(point.ts));
if (!isRunwaySensorAggregateSource) finalMadisObs.forEach((point) => timelineSet.add(point.ts));
if (shouldRenderMetar) metarObs.forEach((point) => timelineSet.add(point.ts));
let debPath: ReturnType<typeof buildDebBaselinePath> | null = null;
@@ -1416,7 +1472,7 @@ function buildFullDayChartData(
// ── Airport Primary (MADIS / AMSC AWOS) ──
// Skip this series for AMSC AWOS cities — their data is redundant with
// runway sensor data and adds a confusing "AMSC AWOS" label to the chart.
if (finalMadisObs.length && !isAmscSource) {
if (finalMadisObs.length && !isRunwaySensorAggregateSource) {
const madisVals = valuesAtTimeline(n, indexByTs, finalMadisObs);
if (madisVals.some((v) => v !== null)) {
series.push({
@@ -1672,6 +1728,13 @@ function getPeakGlowState(
observedHigh,
};
const hotWindowRange = getDebPeakWindowRange(data, series);
const hotWindowStart =
hotWindowRange ? validNumber(data[hotWindowRange[0]]?.ts) : null;
if (hotWindowStart !== null && latest.ts < hotWindowStart) {
return { state: "none", ...metaBase };
}
const nearThreshold = chartDeltaForCelsius(row, 0.5);
const watchThreshold = chartDeltaForCelsius(row, 1);
const flatTrendFloor = -chartDeltaForCelsius(row, 0.2);