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( function peakGlowTitle(
state: keyof typeof PEAK_GLOW_PANEL_CLASS, state: keyof typeof PEAK_GLOW_PANEL_CLASS,
distanceToDeb: number | null, distanceToHigh: number | null,
isEn: boolean, isEn: boolean,
) { ) {
const label = peakGlowLabel(state, isEn); const label = peakGlowLabel(state, isEn);
if (!label) return ""; if (!label) return "";
if (distanceToDeb === null) return label; if (distanceToHigh === null) return label;
const absDistance = Math.abs(distanceToDeb).toFixed(1); const absDistance = Math.abs(distanceToHigh).toFixed(1);
if (state === "breakout" && distanceToDeb <= 0) { if (state === "breakout") {
return isEn ? `${label}: ${absDistance}° above DEB` : `${label}高于 DEB ${absDistance}°`; return isEn ? `${label}: new observed high` : `${label}刷新实测高点`;
} }
if (state === "cooling") return isEn ? `${label}: peak likely passed` : `${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) { function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) {
@@ -141,6 +141,7 @@ export function LiveTemperatureThresholdChart({
setViewMode("auto"); setViewMode("auto");
setShowRunwayDetails(true); setShowRunwayDetails(true);
setHourly(seedHourlyForecastFromRow(row)); setHourly(seedHourlyForecastFromRow(row));
setLiveTemp(null);
setIsHourlyLoading(Boolean(city)); setIsHourlyLoading(Boolean(city));
hasLoadedHourlyDetailRef.current = false; hasLoadedHourlyDetailRef.current = false;
lastPatchAtRef.current = Date.now(); 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", "ml-1 rounded border px-1.5 py-0.5 text-[9px] font-black normal-case tracking-normal",
PEAK_GLOW_BADGE_CLASS[peakGlow.state], PEAK_GLOW_BADGE_CLASS[peakGlow.state],
)} )}
title={peakGlowTitle(peakGlow.state, peakGlow.distanceToDeb, isEn)} title={peakGlowTitle(peakGlow.state, peakGlow.distanceToHigh, isEn)}
> >
{peakGlowLabel(peakGlow.state, isEn)} {peakGlowLabel(peakGlow.state, isEn)}
</span> </span>
@@ -24,46 +24,36 @@ function runwayKey(rwy: string) {
export function runTests() { export function runTests() {
const peakGlowSeries = [ const peakGlowSeries = [
{
key: "hourly_forecast",
label: "DEB Forecast",
source: "DEB Hourly",
color: "#f97316",
values: [26, 29, 32, 32, 30],
},
{ {
key: "madis", key: "madis",
label: "METAR", label: "METAR",
source: "METAR", source: "METAR",
color: "#0284c7", color: "#0284c7",
values: [26.0, 29.0, null, 30.4, null], values: [26.0, 30.35, 30.4, null],
}, },
] as any; ] as any;
const peakGlowData = [ const peakGlowData = [
{ ts: Date.UTC(2026, 4, 27, 10, 0), hourly_forecast: 26, madis: 26.0 }, { 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, 11, 0), hourly_forecast: 29, madis: 30.35 },
{ ts: Date.UTC(2026, 4, 27, 12, 0), hourly_forecast: 32, madis: null }, { 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: 30.4 }, { ts: Date.UTC(2026, 4, 27, 13, 0), hourly_forecast: 32, madis: null },
{ ts: Date.UTC(2026, 4, 27, 14, 0), hourly_forecast: 30, madis: null },
] as any; ] as any;
assert( assert(
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, peakGlowData, peakGlowSeries).state === "near_peak", __getPeakGlowStateForTest({ temp_symbol: "°C", current_max_so_far: 30.5 } 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", "city chart should enter near-peak glow from observed daily high proximity without requiring a DEB curve",
); );
assert( assert(
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, peakGlowData, [ __getPeakGlowStateForTest({ temp_symbol: "°C", current_max_so_far: 30.6 } as any, peakGlowData, [
peakGlowSeries[0], { ...peakGlowSeries[0], values: [26.0, 29.75, 29.8, null] },
{ ...peakGlowSeries[1], values: [26.0, 29.0, null, 29.2, null] },
] as any).state === "watch", ] 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( assert(
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, peakGlowData, [ __getPeakGlowStateForTest({ temp_symbol: "°C" } as any, peakGlowData, [
peakGlowSeries[0], { ...peakGlowSeries[0], values: [26.0, 29.0, 30.4, null] },
{ ...peakGlowSeries[1], values: [26.0, 29.0, null, 32.1, null] },
] as any).state === "breakout", ] 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( assert(
__getPeakGlowStateForTest({ temp_symbol: "°C" } as any, [ __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, 13, 0), hourly_forecast: 30, madis: 30.8 },
{ ts: Date.UTC(2026, 4, 27, 14, 0), hourly_forecast: 27, madis: 30.2 }, { ts: Date.UTC(2026, 4, 27, 14, 0), hourly_forecast: 27, madis: 30.2 },
] as any, [ ] as any, [
{ ...peakGlowSeries[0], values: [26, 30, 32, 30, 27] }, { ...peakGlowSeries[0], values: [26.0, 30.0, 31.8, 30.8, 30.2] },
{ ...peakGlowSeries[1], values: [26.0, 30.0, 31.8, 30.8, 30.2] },
] as any).state === "cooling", ] 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( assert(
__getPeakGlowStateForTest({ temp_symbol: "°F" } as any, peakGlowData, [ __getPeakGlowStateForTest({ temp_symbol: "°F", current_max_so_far: 88 } as any, peakGlowData, [
{ ...peakGlowSeries[0], values: [79, 84, 90, 90, 86] }, { ...peakGlowSeries[0], values: [80, 86.3, 86.4, null] },
{ ...peakGlowSeries[1], values: [80, 84, null, 86.2, null] },
] as any).state === "watch", ] 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 = { 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(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"); 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( const chengduFromAmosSnapshot = __buildTemperatureChartDataForTest(
{ {
city: "chengdu", city: "chengdu",
@@ -393,6 +414,44 @@ export function runTests() {
assert(chengduAuxRunway, "AMOS runway_obs snapshot should create auxiliary runway chart lines"); assert(chengduAuxRunway, "AMOS runway_obs snapshot should create auxiliary runway chart lines");
assert(chengduAuxRunway.dashed === true, "AMOS snapshot auxiliary runway should be dashed"); 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( const newYorkMetrics = __getObservationDisplayMetricsForTest(
{ {
city: "new york", city: "new york",
@@ -15,6 +15,10 @@ export function runTests() {
path.join(projectRoot, "components", "dashboard", "scan-terminal", "GridLayoutSelector.tsx"), path.join(projectRoot, "components", "dashboard", "scan-terminal", "GridLayoutSelector.tsx"),
"utf8", "utf8",
); );
const chartSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"),
"utf8",
);
assert( assert(
dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"), dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"),
@@ -46,4 +50,9 @@ export function runTests() {
dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"), dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"),
"stale saved chart slots must render the empty city picker instead of a row=null Temperature Chart", "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 = { type PeakGlowMeta = {
state: PeakGlowState; state: PeakGlowState;
currentTemp: number | null; currentTemp: number | null;
debPeak: number | null; referenceHigh: number | null;
distanceToDeb: number | null; distanceToHigh: number | null;
trend30m: number | null; trend30m: number | null;
trend60m: number | null; trend60m: number | null;
observedHigh: number | null; observedHigh: number | null;
peakStartTs: number | null;
peakEndTs: number | null;
}; };
type RunwayHistorySeries = { type RunwayHistorySeries = {
@@ -985,7 +983,7 @@ function buildRunwayHistorySeries(
((row as any)?.runway_plate_history as Record<string, Array<Record<string, unknown>>> | undefined); ((row as any)?.runway_plate_history as Record<string, Array<Record<string, unknown>>> | undefined);
if (directHistory && typeof directHistory === "object") { if (directHistory && typeof directHistory === "object") {
return Object.entries(directHistory) const directSeries = Object.entries(directHistory)
.map(([rwy, rawPoints], index) => { .map(([rwy, rawPoints], index) => {
const normalizedRwy = String(rwy || `RWY ${index + 1}`).trim(); const normalizedRwy = String(rwy || `RWY ${index + 1}`).trim();
const points = (Array.isArray(rawPoints) ? rawPoints : []) const points = (Array.isArray(rawPoints) ? rawPoints : [])
@@ -1008,6 +1006,7 @@ function buildRunwayHistorySeries(
}; };
}) })
.filter((series) => series.points.length > 1); .filter((series) => series.points.length > 1);
if (directSeries.length) return directSeries;
} }
const amos = hourly?.amos; const amos = hourly?.amos;
@@ -1310,6 +1309,9 @@ function buildFullDayChartData(
if (isHKO) { if (isHKO) {
finalSettlementObs = madisObs; finalSettlementObs = madisObs;
finalMadisObs = settlementObs; finalMadisObs = settlementObs;
} else if (isShenzhen && !settlementObs.length && madisObs.length) {
finalSettlementObs = madisObs;
finalMadisObs = [];
} }
// ── Runway band & max series ── // ── Runway band & max series ──
@@ -1615,42 +1617,6 @@ function getLiveObservationPoints(
return points.sort((left, right) => left.ts - right.ts); 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( function pointAtOrBefore(
points: Array<{ ts: number; temp: number }>, points: Array<{ ts: number; temp: number }>,
targetTs: number, targetTs: number,
@@ -1670,79 +1636,66 @@ function getPeakGlowState(
const empty: PeakGlowMeta = { const empty: PeakGlowMeta = {
state: "none", state: "none",
currentTemp: null, currentTemp: null,
debPeak: null, referenceHigh: null,
distanceToDeb: null, distanceToHigh: null,
trend30m: null, trend30m: null,
trend60m: null, trend60m: null,
observedHigh: null, observedHigh: null,
peakStartTs: null,
peakEndTs: null,
}; };
const livePoints = getLiveObservationPoints(data, series); const livePoints = getLiveObservationPoints(data, series);
const latest = livePoints[livePoints.length - 1] || null; const latest = livePoints[livePoints.length - 1] || null;
const debProfile = getDebPeakProfile(row, data, series); if (!latest) return empty;
if (!latest || !debProfile) return empty;
const peakStartTs = debProfile.hotStartTs;
const peakEndTs = debProfile.hotEndTs;
const previousLivePoints = livePoints.filter((point) => point.ts < latest.ts); const previousLivePoints = livePoints.filter((point) => point.ts < latest.ts);
const previousHigh = previousLivePoints.length const previousHigh = previousLivePoints.length
? Math.max(...previousLivePoints.map((point) => point.temp)) ? Math.max(...previousLivePoints.map((point) => point.temp))
: null; : 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 trend30Base = pointAtOrBefore(livePoints, latest.ts - 30 * 60 * 1000);
const trend60Base = pointAtOrBefore(livePoints, latest.ts - 60 * 60 * 1000); const trend60Base = pointAtOrBefore(livePoints, latest.ts - 60 * 60 * 1000);
const trend30m = trend30Base ? latest.temp - trend30Base.temp : null; const trend30m = trend30Base ? latest.temp - trend30Base.temp : null;
const trend60m = trend60Base ? latest.temp - trend60Base.temp : null; const trend60m = trend60Base ? latest.temp - trend60Base.temp : null;
const distanceToDeb = debProfile.peak.temp - latest.temp; const distanceToHigh = observedHigh - latest.temp;
const metaBase = { const metaBase = {
currentTemp: latest.temp, currentTemp: latest.temp,
debPeak: debProfile.peak.temp, referenceHigh: observedHigh,
distanceToDeb, distanceToHigh,
trend30m, trend30m,
trend60m, trend60m,
observedHigh, observedHigh,
peakStartTs,
peakEndTs,
}; };
const nearThreshold = chartDeltaForCelsius(row, 2); const nearThreshold = chartDeltaForCelsius(row, 0.5);
const watchThreshold = chartDeltaForCelsius(row, 3); const watchThreshold = chartDeltaForCelsius(row, 1);
const breakoutNearThreshold = chartDeltaForCelsius(row, 0.5);
const flatTrendFloor = -chartDeltaForCelsius(row, 0.2); const flatTrendFloor = -chartDeltaForCelsius(row, 0.2);
const coolingDrop = -chartDeltaForCelsius(row, 0.5); const coolingDrop = -chartDeltaForCelsius(row, 0.5);
const breakoutStep = chartDeltaForCelsius(row, 0.1); 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 = const isCooling =
afterPeak && distanceToHigh >= Math.abs(coolingDrop) &&
((trend60m !== null && trend60m <= coolingDrop) || ((trend60m !== null && trend60m <= coolingDrop) ||
(previousHigh !== null && latest.temp <= previousHigh + coolingDrop)); (previousHigh !== null && latest.temp <= previousHigh + coolingDrop));
if (isCooling) return { state: "cooling", ...metaBase }; if (isCooling) return { state: "cooling", ...metaBase };
const isBreakout = const isBreakout =
latest.temp >= debProfile.peak.temp || previousHigh !== null &&
(distanceToDeb <= breakoutNearThreshold && latest.temp > previousHigh + breakoutStep;
previousHigh !== null &&
latest.temp > previousHigh + breakoutStep);
if (isBreakout) return { state: "breakout", ...metaBase }; if (isBreakout) return { state: "breakout", ...metaBase };
if ( if (
distanceToDeb <= nearThreshold && distanceToHigh <= nearThreshold &&
(trend30m === null || trend30m >= flatTrendFloor) (trend30m === null || trend30m >= flatTrendFloor)
) { ) {
return { state: "near_peak", ...metaBase }; return { state: "near_peak", ...metaBase };
} }
const isNearPeakTime = if (distanceToHigh <= watchThreshold) {
peakStartTs !== null &&
peakEndTs !== null &&
latest.ts <= peakEndTs &&
peakStartTs - latest.ts <= 3 * 60 * 60 * 1000;
if (distanceToDeb <= watchThreshold || isNearPeakTime) {
return { state: "watch", ...metaBase }; return { state: "watch", ...metaBase };
} }