feat: implement runway temperature charting logic and UI components for scan terminal dashboard

This commit is contained in:
2569718930@qq.com
2026-05-27 08:07:49 +08:00
parent 79d94bed5f
commit 65fe2d7361
4 changed files with 131 additions and 109 deletions
@@ -62,18 +62,18 @@ function peakGlowLabel(state: keyof typeof PEAK_GLOW_PANEL_CLASS, isEn: boolean)
function peakGlowTitle(
state: keyof typeof PEAK_GLOW_PANEL_CLASS,
distanceToDeb: number | null,
distanceToHigh: number | null,
isEn: boolean,
) {
const label = peakGlowLabel(state, isEn);
if (!label) return "";
if (distanceToDeb === null) return label;
const absDistance = Math.abs(distanceToDeb).toFixed(1);
if (state === "breakout" && distanceToDeb <= 0) {
return isEn ? `${label}: ${absDistance}° above DEB` : `${label}高于 DEB ${absDistance}°`;
if (distanceToHigh === null) return label;
const absDistance = Math.abs(distanceToHigh).toFixed(1);
if (state === "breakout") {
return isEn ? `${label}: new observed high` : `${label}刷新实测高点`;
}
if (state === "cooling") return isEn ? `${label}: peak likely passed` : `${label}:峰值可能已过`;
return isEn ? `${label}: ${absDistance}° below DEB` : `${label}:距 DEB ${absDistance}°`;
return isEn ? `${label}: ${absDistance}° below observed high` : `${label}:距实测高点 ${absDistance}°`;
}
function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) {
@@ -141,6 +141,7 @@ export function LiveTemperatureThresholdChart({
setViewMode("auto");
setShowRunwayDetails(true);
setHourly(seedHourlyForecastFromRow(row));
setLiveTemp(null);
setIsHourlyLoading(Boolean(city));
hasLoadedHourlyDetailRef.current = false;
lastPatchAtRef.current = Date.now();
@@ -490,7 +491,7 @@ export function LiveTemperatureThresholdChart({
"ml-1 rounded border px-1.5 py-0.5 text-[9px] font-black normal-case tracking-normal",
PEAK_GLOW_BADGE_CLASS[peakGlow.state],
)}
title={peakGlowTitle(peakGlow.state, peakGlow.distanceToDeb, isEn)}
title={peakGlowTitle(peakGlow.state, peakGlow.distanceToHigh, isEn)}
>
{peakGlowLabel(peakGlow.state, isEn)}
</span>
@@ -24,46 +24,36 @@ function runwayKey(rwy: string) {
export function runTests() {
const peakGlowSeries = [
{
key: "hourly_forecast",
label: "DEB Forecast",
source: "DEB Hourly",
color: "#f97316",
values: [26, 29, 32, 32, 30],
},
{
key: "madis",
label: "METAR",
source: "METAR",
color: "#0284c7",
values: [26.0, 29.0, null, 30.4, null],
values: [26.0, 30.35, 30.4, null],
},
] as any;
const peakGlowData = [
{ ts: Date.UTC(2026, 4, 27, 10, 0), hourly_forecast: 26, madis: 26.0 },
{ ts: Date.UTC(2026, 4, 27, 11, 0), hourly_forecast: 29, madis: 29.0 },
{ ts: Date.UTC(2026, 4, 27, 12, 0), hourly_forecast: 32, madis: null },
{ ts: Date.UTC(2026, 4, 27, 13, 0), hourly_forecast: 32, madis: 30.4 },
{ ts: Date.UTC(2026, 4, 27, 14, 0), hourly_forecast: 30, madis: null },
{ ts: Date.UTC(2026, 4, 27, 11, 0), hourly_forecast: 29, madis: 30.35 },
{ ts: Date.UTC(2026, 4, 27, 12, 0), hourly_forecast: 32, madis: 30.4 },
{ ts: Date.UTC(2026, 4, 27, 13, 0), hourly_forecast: 32, madis: null },
] as any;
assert(
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, peakGlowData, peakGlowSeries).state === "near_peak",
"city chart should enter near-peak glow when live temperature is within 2C of DEB and not cooling",
__getPeakGlowStateForTest({ temp_symbol: "°C", current_max_so_far: 30.5 } as any, peakGlowData, peakGlowSeries).state === "near_peak",
"city chart should enter near-peak glow from observed daily high proximity without requiring a DEB curve",
);
assert(
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, peakGlowData, [
peakGlowSeries[0],
{ ...peakGlowSeries[1], values: [26.0, 29.0, null, 29.2, null] },
__getPeakGlowStateForTest({ temp_symbol: "°C", current_max_so_far: 30.6 } as any, peakGlowData, [
{ ...peakGlowSeries[0], values: [26.0, 29.75, 29.8, null] },
] as any).state === "watch",
"city chart should enter watch glow when live temperature is within 3C of DEB but not near peak",
"city chart should enter watch glow when live temperature is near the observed daily high but not close enough for near-peak",
);
assert(
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, peakGlowData, [
peakGlowSeries[0],
{ ...peakGlowSeries[1], values: [26.0, 29.0, null, 32.1, null] },
{ ...peakGlowSeries[0], values: [26.0, 29.0, 30.4, null] },
] as any).state === "breakout",
"city chart should use breakout glow when live temperature reaches or exceeds DEB peak",
"city chart should use breakout glow when live observations print a new intraday high without referencing DEB",
);
assert(
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, [
@@ -73,17 +63,15 @@ export function runTests() {
{ ts: Date.UTC(2026, 4, 27, 13, 0), hourly_forecast: 30, madis: 30.8 },
{ ts: Date.UTC(2026, 4, 27, 14, 0), hourly_forecast: 27, madis: 30.2 },
] as any, [
{ ...peakGlowSeries[0], values: [26, 30, 32, 30, 27] },
{ ...peakGlowSeries[1], values: [26.0, 30.0, 31.8, 30.8, 30.2] },
{ ...peakGlowSeries[0], values: [26.0, 30.0, 31.8, 30.8, 30.2] },
] as any).state === "cooling",
"city chart should stop hot glow and show cooling state after peak when live temperature rolls over",
"city chart should show cooling state from observed rollover without using the DEB forecast curve",
);
assert(
__getPeakGlowStateForTest({ temp_symbol: "°F" } as any, peakGlowData, [
{ ...peakGlowSeries[0], values: [79, 84, 90, 90, 86] },
{ ...peakGlowSeries[1], values: [80, 84, null, 86.2, null] },
__getPeakGlowStateForTest({ temp_symbol: "°F", current_max_so_far: 88 } as any, peakGlowData, [
{ ...peakGlowSeries[0], values: [80, 86.3, 86.4, null] },
] as any).state === "watch",
"US Fahrenheit charts should convert Celsius thresholds before deciding peak glow state",
"US Fahrenheit charts should convert Celsius thresholds against observed highs before deciding peak glow state",
);
const guangzhou = {
@@ -349,6 +337,39 @@ 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 shenzhenAirportPrimaryHko = __buildTemperatureChartDataForTest(
{
city: "shenzhen",
local_date: "2026-05-27",
local_time: "07:55",
tz_offset_seconds: 8 * 60 * 60,
temp_symbol: "°C",
} as any,
{
localTime: "07:55",
times: ["10:00", "14:00", "18:00"],
temps: [30.2, 31.8, 30.7],
airportPrimary: {
source_code: "hko",
source_label: "HKO",
temp: 29.9,
obs_time: "2026-05-26T23:55:00Z",
},
airportPrimaryTodayObs: [
["2026-05-26T23:15:00Z", 29.5],
["2026-05-26T23:25:00Z", 29.7],
["2026-05-26T23:35:00Z", 29.9],
],
} as any,
"1D",
);
const shenzhenHkoSeries = seriesByKey(shenzhenAirportPrimaryHko.series, "settlement") as any;
assert(shenzhenHkoSeries?.label === "HKO", "Shenzhen airport-primary HKO history should render as the HKO observation series");
assert(
shenzhenHkoSeries.values.filter((value: number | null) => value !== null).length >= 2,
"Shenzhen HKO observation series should include the airportPrimaryTodayObs curve points",
);
const chengduFromAmosSnapshot = __buildTemperatureChartDataForTest(
{
city: "chengdu",
@@ -393,6 +414,44 @@ export function runTests() {
assert(chengduAuxRunway, "AMOS runway_obs snapshot should create auxiliary runway chart lines");
assert(chengduAuxRunway.dashed === true, "AMOS snapshot auxiliary runway should be dashed");
const shanghaiWithEmptyRunwayHistory = __buildTemperatureChartDataForTest(
{
city: "shanghai",
local_date: "2026-05-27",
local_time: "07:59",
tz_offset_seconds: 8 * 60 * 60,
temp_symbol: "°C",
} as any,
{
localTime: "07:59",
times: ["10:00", "14:00", "18:00"],
temps: [25, 28, 24],
runwayPlateHistory: {},
amos: {
observation_time_local: "2026-05-27 07:59:00",
runway_obs: {
runway_pairs: [
["35R", "17L"],
["34L", "16R"],
],
temperatures: [
[25.8],
[25.4],
],
point_temperatures: [
{ runway: "35R/17L", tdz_temp: 25.8, mid_temp: null, end_temp: 26.2 },
{ runway: "34L/16R", tdz_temp: 25.4, mid_temp: null, end_temp: 25.7 },
],
},
},
} as any,
"1D",
);
assert(
seriesByKey(shanghaiWithEmptyRunwayHistory.series, runwayKey("35R/17L")),
"empty runwayPlateHistory should fall back to AMOS runway_obs so runway cities still draw runway curves",
);
const newYorkMetrics = __getObservationDisplayMetricsForTest(
{
city: "new york",
@@ -15,6 +15,10 @@ export function runTests() {
path.join(projectRoot, "components", "dashboard", "scan-terminal", "GridLayoutSelector.tsx"),
"utf8",
);
const chartSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"),
"utf8",
);
assert(
dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"),
@@ -46,4 +50,9 @@ export function runTests() {
dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"),
"stale saved chart slots must render the empty city picker instead of a row=null Temperature Chart",
);
assert(
chartSource.includes("setLiveTemp(null);") &&
chartSource.includes("lastAppliedPatchRevisionRef.current = 0;"),
"switching city slots must clear the previous live temperature so Fahrenheit values cannot leak into Celsius charts",
);
}
@@ -192,13 +192,11 @@ type PeakGlowState = "none" | "watch" | "near_peak" | "breakout" | "cooling";
type PeakGlowMeta = {
state: PeakGlowState;
currentTemp: number | null;
debPeak: number | null;
distanceToDeb: number | null;
referenceHigh: number | null;
distanceToHigh: number | null;
trend30m: number | null;
trend60m: number | null;
observedHigh: number | null;
peakStartTs: number | null;
peakEndTs: number | null;
};
type RunwayHistorySeries = {
@@ -985,7 +983,7 @@ function buildRunwayHistorySeries(
((row as any)?.runway_plate_history as Record<string, Array<Record<string, unknown>>> | undefined);
if (directHistory && typeof directHistory === "object") {
return Object.entries(directHistory)
const directSeries = Object.entries(directHistory)
.map(([rwy, rawPoints], index) => {
const normalizedRwy = String(rwy || `RWY ${index + 1}`).trim();
const points = (Array.isArray(rawPoints) ? rawPoints : [])
@@ -1008,6 +1006,7 @@ function buildRunwayHistorySeries(
};
})
.filter((series) => series.points.length > 1);
if (directSeries.length) return directSeries;
}
const amos = hourly?.amos;
@@ -1310,6 +1309,9 @@ function buildFullDayChartData(
if (isHKO) {
finalSettlementObs = madisObs;
finalMadisObs = settlementObs;
} else if (isShenzhen && !settlementObs.length && madisObs.length) {
finalSettlementObs = madisObs;
finalMadisObs = [];
}
// ── Runway band & max series ──
@@ -1615,42 +1617,6 @@ function getLiveObservationPoints(
return points.sort((left, right) => left.ts - right.ts);
}
function getDebPeakProfile(
row: ScanOpportunityRow | null,
data: Array<Record<string, any>>,
series: EvidenceSeries[],
) {
const debSeries = series.find((item) => item.key === "hourly_forecast");
if (!debSeries) return null;
const debPoints = debSeries.values
.map((value, index) => {
const ts = typeof data[index]?.ts === "number" ? data[index].ts : null;
const temp = validNumber(value);
return ts === null || temp === null ? null : { index, ts, temp };
})
.filter((point): point is { index: number; ts: number; temp: number } => point !== null);
if (!debPoints.length) return null;
const peak = debPoints.reduce((best, point) => (point.temp > best.temp ? point : best), debPoints[0]);
let peakIndex = debPoints.findIndex((point) => point.index === peak.index);
if (peakIndex < 0) peakIndex = 0;
const hotThreshold = peak.temp - chartDeltaForCelsius(row, 2);
let hotStartIndex = peakIndex;
let hotEndIndex = peakIndex;
while (hotStartIndex > 0 && debPoints[hotStartIndex - 1].temp >= hotThreshold) {
hotStartIndex -= 1;
}
while (hotEndIndex < debPoints.length - 1 && debPoints[hotEndIndex + 1].temp >= hotThreshold) {
hotEndIndex += 1;
}
return {
peak,
hotStartTs: debPoints[hotStartIndex].ts,
hotEndTs: debPoints[hotEndIndex].ts,
};
}
function pointAtOrBefore(
points: Array<{ ts: number; temp: number }>,
targetTs: number,
@@ -1670,79 +1636,66 @@ function getPeakGlowState(
const empty: PeakGlowMeta = {
state: "none",
currentTemp: null,
debPeak: null,
distanceToDeb: null,
referenceHigh: null,
distanceToHigh: null,
trend30m: null,
trend60m: null,
observedHigh: null,
peakStartTs: null,
peakEndTs: null,
};
const livePoints = getLiveObservationPoints(data, series);
const latest = livePoints[livePoints.length - 1] || null;
const debProfile = getDebPeakProfile(row, data, series);
if (!latest || !debProfile) return empty;
if (!latest) return empty;
const peakStartTs = debProfile.hotStartTs;
const peakEndTs = debProfile.hotEndTs;
const previousLivePoints = livePoints.filter((point) => point.ts < latest.ts);
const previousHigh = previousLivePoints.length
? Math.max(...previousLivePoints.map((point) => point.temp))
: null;
const observedHigh = Math.max(...livePoints.map((point) => point.temp));
const liveHigh = Math.max(...livePoints.map((point) => point.temp));
const rowHigh = validNumber(
row?.current_max_so_far ??
row?.metar_context?.airport_max_so_far ??
row?.metar_context?.max_temp,
);
const observedHigh = rowHigh !== null ? Math.max(liveHigh, rowHigh) : liveHigh;
const trend30Base = pointAtOrBefore(livePoints, latest.ts - 30 * 60 * 1000);
const trend60Base = pointAtOrBefore(livePoints, latest.ts - 60 * 60 * 1000);
const trend30m = trend30Base ? latest.temp - trend30Base.temp : null;
const trend60m = trend60Base ? latest.temp - trend60Base.temp : null;
const distanceToDeb = debProfile.peak.temp - latest.temp;
const distanceToHigh = observedHigh - latest.temp;
const metaBase = {
currentTemp: latest.temp,
debPeak: debProfile.peak.temp,
distanceToDeb,
referenceHigh: observedHigh,
distanceToHigh,
trend30m,
trend60m,
observedHigh,
peakStartTs,
peakEndTs,
};
const nearThreshold = chartDeltaForCelsius(row, 2);
const watchThreshold = chartDeltaForCelsius(row, 3);
const breakoutNearThreshold = chartDeltaForCelsius(row, 0.5);
const nearThreshold = chartDeltaForCelsius(row, 0.5);
const watchThreshold = chartDeltaForCelsius(row, 1);
const flatTrendFloor = -chartDeltaForCelsius(row, 0.2);
const coolingDrop = -chartDeltaForCelsius(row, 0.5);
const breakoutStep = chartDeltaForCelsius(row, 0.1);
const phase = String((row as any)?.window_phase || "").toLowerCase();
const afterPeak =
phase === "post_peak" ||
(peakEndTs !== null && latest.ts > peakEndTs);
const isCooling =
afterPeak &&
distanceToHigh >= Math.abs(coolingDrop) &&
((trend60m !== null && trend60m <= coolingDrop) ||
(previousHigh !== null && latest.temp <= previousHigh + coolingDrop));
if (isCooling) return { state: "cooling", ...metaBase };
const isBreakout =
latest.temp >= debProfile.peak.temp ||
(distanceToDeb <= breakoutNearThreshold &&
previousHigh !== null &&
latest.temp > previousHigh + breakoutStep);
previousHigh !== null &&
latest.temp > previousHigh + breakoutStep;
if (isBreakout) return { state: "breakout", ...metaBase };
if (
distanceToDeb <= nearThreshold &&
distanceToHigh <= nearThreshold &&
(trend30m === null || trend30m >= flatTrendFloor)
) {
return { state: "near_peak", ...metaBase };
}
const isNearPeakTime =
peakStartTs !== null &&
peakEndTs !== null &&
latest.ts <= peakEndTs &&
peakStartTs - latest.ts <= 3 * 60 * 60 * 1000;
if (distanceToDeb <= watchThreshold || isNearPeakTime) {
if (distanceToHigh <= watchThreshold) {
return { state: "watch", ...metaBase };
}