From 93fe7a9b692b35b8d13b208f5f1a4f3527c8adc4 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 10 Jun 2026 12:56:43 +0800 Subject: [PATCH] Plot live chart observations immediately --- .../LiveTemperatureThresholdChart.tsx | 2 +- .../__tests__/refreshCadencePolicy.test.ts | 5 + .../__tests__/temperatureChartData.test.ts | 48 +++++- .../scan-terminal/temperature-chart-logic.ts | 137 +++++++++++++++++- 4 files changed, 184 insertions(+), 8 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 43c31af4..fc0c6da5 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -57,7 +57,7 @@ const PEAK_GLOW_BADGE_CLASS = { cooling: "border-slate-200 bg-slate-100 text-slate-500", } as const; -const PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000; +const PROBABILITY_REFRESH_AFTER_PATCH_MS = DASHBOARD_REFRESH_POLICY_MS.metar; const FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS = 90_000; const NO_PATCH_FULL_DETAIL_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.metar; const DETAIL_LOAD_BATCH_DELAY_MS = 0; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index 311b8122..c98117a8 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -94,6 +94,11 @@ export async function runTests() { chartSource.includes("setHourly(data)"), "visible chart fallback must refresh the full city detail payload at the current chart resolution when SSE patches stop", ); + assert( + chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS = DASHBOARD_REFRESH_POLICY_MS.metar") && + !chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000"), + "live observation patches should update the plotted line immediately and throttle heavy probability/detail refreshes to METAR cadence", + ); assert( chartLogicSource.includes("HOURLY_FORCE_REFRESH_DEDUP_MS") && chartLogicSource.includes("maxAgeMs: HOURLY_FORCE_REFRESH_DEDUP_MS") && diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index 223b4d2f..5f327067 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -1,7 +1,11 @@ import { getTemperatureChartData } from "@/lib/chart-utils"; import type { CityDetail } from "@/lib/dashboard-types"; import { buildDebBaselinePath } from "@/lib/temperature-chart-paths"; -import { buildFullDayChartData } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; +import { + buildFullDayChartData, + mergePatchIntoHourly, + seedHourlyForecastFromRow, +} from "@/components/dashboard/scan-terminal/temperature-chart-logic"; function assert(condition: unknown, message: string) { if (!condition) throw new Error(message); @@ -247,4 +251,46 @@ export function runTests() { normalBaseline.debPast.some((t) => t != null) && normalBaseline.debFuture.some((t) => t != null), "Normal city: both past and future portions should have data", ); + + const guangzhouRow = { + city: "guangzhou", + local_date: "2026-06-10", + local_time: "12:45", + current_temp: 28.4, + current_max_so_far: 29, + temp_symbol: "°C", + tz_offset_seconds: 8 * 3600, + metar_context: { + source: "amsc_awos", + station_label: "AMSC AWOS", + airport_current_temp: 28.4, + airport_obs_time: "12:45", + airport_max_so_far: 29, + }, + } as any; + const seededGuangzhou = seedHourlyForecastFromRow(guangzhouRow); + const guangzhouLiveChart = buildFullDayChartData(guangzhouRow, seededGuangzhou, false); + const guangzhouSettlementRunway = guangzhouLiveChart.series.find((item) => item.key === "runway_02L_20R"); + assert( + guangzhouSettlementRunway?.values.some((value) => value === 28.4), + "row-seeded runway cities must plot the latest scan-row observation immediately instead of waiting for city detail", + ); + + const guangzhouPatched = mergePatchIntoHourly(seededGuangzhou, { + city: "guangzhou", + revision: 42, + changes: { + temp: 28.8, + observed_at_local: "12:48", + obs_time: "12:48", + source: "amsc_awos", + runway_points: [{ runway: "02L/20R", temp: 28.8 }], + }, + }); + const guangzhouPatchedChart = buildFullDayChartData(guangzhouRow, guangzhouPatched, false); + const patchedRunway = guangzhouPatchedChart.series.find((item) => item.key === "runway_02L_20R"); + assert( + patchedRunway?.values.some((value) => value === 28.8), + "SSE runway patches must append directly to the chart series without waiting for a force-refreshed detail payload", + ); } diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index 2f184b62..64b7e117 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -597,7 +597,7 @@ function filterRunwayHistoryToLocalDay( ...item, points: filterTimelinePointsToLocalDay(item.points, bounds), })) - .filter((item) => item.points.length > 1); + .filter((item) => item.points.length > 0); } function formatTimestamp(ts: number): string { @@ -938,6 +938,103 @@ function runwayPatchPointsFromRunwayObs(runwayObs: any) { .filter((point: any): point is { runway: string; temp: number; target_runway_max: number } => point !== null); } +function rowCurrentObservation(row: ScanOpportunityRow | null) { + if (!row) return null; + const metarContext = row.metar_context || null; + const temp = + validNumber(row.current_temp) ?? + validNumber(metarContext?.airport_current_temp) ?? + validNumber(metarContext?.last_temp); + const time = + metarContext?.airport_obs_time || + metarContext?.last_observation_time || + metarContext?.last_time || + row.local_time || + null; + if (temp === null || !time) return null; + const maxSoFar = + validNumber(row.current_max_so_far) ?? + validNumber(metarContext?.airport_max_so_far) ?? + validNumber(metarContext?.max_temp) ?? + temp; + return { + temp, + time: String(time), + maxSoFar, + sourceCode: String(metarContext?.source || "").trim() || null, + sourceLabel: String(metarContext?.station_label || metarContext?.source || "").trim() || null, + }; +} + +function appendRawObservationPoint( + points: RawObsPoint[] | null | undefined, + time: string, + temp: number, +) { + const merged = [...(points || [])]; + const normalizedTime = String(time || "").trim(); + if (!normalizedTime) return merged; + const exists = merged + .map(normalizeRawObsPoint) + .some((point) => point?.time === normalizedTime && validNumber(point.temp) === temp); + if (!exists) merged.push([normalizedTime, temp]); + return merged.slice(-MAX_OBS_POINTS); +} + +function rowLooksLikeRunwaySensor(row: ScanOpportunityRow | null) { + const cityKey = normalizeCityKey(row?.city); + const sourceText = [ + row?.metar_context?.source, + row?.metar_context?.station, + row?.metar_context?.station_label, + row?.airport, + ] + .map((value) => String(value || "").toLowerCase()) + .join(" "); + return ( + AMSC_RUNWAY_CITIES.has(cityKey) || + sourceText.includes("amsc") || + sourceText.includes("awos") || + sourceText.includes("runway") + ); +} + +function seedRunwayPlateHistoryFromRow( + row: ScanOpportunityRow | null, + existing: Record>> | undefined, +) { + const current = rowCurrentObservation(row); + const cityKey = normalizeCityKey(row?.city); + const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || []; + if (!current || !settlementPairs.length || !rowLooksLikeRunwaySensor(row)) { + return existing; + } + + const history: Record>> = {}; + Object.entries(existing || {}).forEach(([rwy, points]) => { + if (Array.isArray(points)) history[rwy] = [...points]; + }); + + settlementPairs.forEach((pair) => { + const rwy = `${normalizeRunwayLabel(pair[0])}/${normalizeRunwayLabel(pair[1])}`; + const rwyHistory = history[rwy] || []; + const exists = rwyHistory.some((point) => { + return point.timestamp === current.time || point.time === current.time || point.observed_at === current.time; + }); + if (exists) return; + history[rwy] = [ + ...rwyHistory, + { + timestamp: current.time, + temp_c: current.temp, + value: current.temp, + }, + ].slice(-MAX_OBS_POINTS); + }); + + return Object.keys(history).length ? history : existing; +} + type HourlyForecast = { forecastTodayHigh?: number | null; debPrediction?: number | null; @@ -966,6 +1063,32 @@ type HourlyForecast = { function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForecast { if (!row) return null; + const current = rowCurrentObservation(row); + const sourceCode = current?.sourceCode || undefined; + const sourceLabel = current?.sourceLabel || undefined; + const airportCurrent = current + ? { + temp: current.temp, + obs_time: current.time, + max_so_far: current.maxSoFar, + source_code: sourceCode, + source_label: sourceLabel, + } + : null; + const airportPrimary = current + ? { + temp: current.temp, + obs_time: current.time, + max_so_far: current.maxSoFar, + source_code: sourceCode, + source_label: sourceLabel, + source: sourceCode, + } + : null; + const seededRunwayPlateHistory = seedRunwayPlateHistoryFromRow( + row, + (row as any)?.runway_plate_history || undefined, + ); return { forecastTodayHigh: null, debPrediction: validNumber(row.deb_prediction), @@ -976,12 +1099,12 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca times: [], temps: [], modelCurves: undefined, - runwayPlateHistory: (row as any)?.runway_plate_history || undefined, + runwayPlateHistory: seededRunwayPlateHistory, runwayBandHistory: undefined, amos: null, current: null, - airportCurrent: null, - airportPrimary: null, + airportCurrent, + airportPrimary, wundergroundCurrent: (row as any)?.wunderground_current || null, forecastDaily: [], multiModelDaily: {}, @@ -992,7 +1115,9 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca }, settlementTodayObs: row.settlement_today_obs || row.metar_context?.settlement_today_obs || undefined, metarTodayObs: row.metar_today_obs || row.metar_context?.today_obs || row.metar_recent_obs || row.metar_context?.recent_obs || undefined, - airportPrimaryTodayObs: undefined, + airportPrimaryTodayObs: current + ? appendRawObservationPoint(undefined, current.time, current.temp) + : undefined, }; } @@ -1942,7 +2067,7 @@ function buildFullDayChartData( localDayBounds, ); const runwayHistorySeries = filterRunwayHistoryToLocalDay( - buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr, 2, isEn), + buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr, 1, isEn), localDayBounds, );