feat: implement runway temperature charting logic and UI components for scan terminal dashboard
This commit is contained in:
@@ -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>
|
||||
|
||||
+87
-28
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user