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