From 65fe2d7361bcfcdc31b5dcbdcdd7e23205564a08 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 27 May 2026 08:07:49 +0800 Subject: [PATCH] feat: implement runway temperature charting logic and UI components for scan terminal dashboard --- .../LiveTemperatureThresholdChart.tsx | 15 +-- ...temperatureDefaultVisibilityPolicy.test.ts | 115 +++++++++++++----- .../__tests__/terminalGridPolicy.test.ts | 9 ++ .../scan-terminal/temperature-chart-logic.ts | 101 ++++----------- 4 files changed, 131 insertions(+), 109 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 521833ae..5dda005f 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -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)} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts index e8f3fdd7..02f8bab2 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts @@ -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", diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts index a054b101..0c5141a4 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts @@ -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", + ); } diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index a0636a3f..68140e9b 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -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>> | 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>, - 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 }; }