From 3a82c11e788832a120941eb61128171ce85faabe Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 26 May 2026 06:13:18 +0800 Subject: [PATCH] feat: implement LiveTemperatureThresholdChart and add visibility and refresh policy unit tests --- .../LiveTemperatureThresholdChart.tsx | 118 ++++++++++++------ .../__tests__/refreshCadencePolicy.test.ts | 6 + ...temperatureDefaultVisibilityPolicy.test.ts | 27 ++++ 3 files changed, 116 insertions(+), 35 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 0bcc49cd..07625500 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -32,10 +32,10 @@ const ROLLING_WINDOW_AFTER_FORECAST_MS = 8 * 60 * 60 * 1000; const SETTLEMENT_RUNWAY_PAIRS: Record> = { shanghai: [["17L", "35R"]], - beijing: [["01", "19"]], + beijing: [["19", "01"]], guangzhou: [["02L", "20R"]], chengdu: [["02L", "20R"]], - chongqing: [["02L", "20R"]], + chongqing: [["20R", "02L"]], wuhan: [["04", "22"]], seoul: [["15R", "33L"]], }; @@ -196,6 +196,7 @@ const FULL_DAY_SLOT_MINUTES = 30; const FULL_DAY_SLOTS = 48; const SLOT_INTERVAL_MS = FULL_DAY_SLOT_MINUTES * 60 * 1000; const _hourlyCache = new Map(); +const _hourlyRequestCache = new Map>(); const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"]; function validNumber(value: unknown): number | null { @@ -375,6 +376,7 @@ function runwayLabelFromPair(rawPair: unknown, index: number) { type HourlyForecast = { forecastTodayHigh?: number | null; + debPrediction?: number | null; localTime?: string | null; times: string[]; temps: Array; @@ -390,6 +392,75 @@ type HourlyForecast = { airportPrimaryTodayObs?: RawObsPoint[]; } | null; +function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForecast { + if (!row) return null; + return { + forecastTodayHigh: null, + debPrediction: validNumber(row.deb_prediction), + localTime: row.local_time || null, + times: [], + temps: [], + modelCurves: undefined, + runwayPlateHistory: (row as any)?.runway_plate_history || undefined, + amos: null, + airportCurrent: null, + airportPrimary: null, + forecastDaily: [], + multiModelDaily: {}, + 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, + }; +} + +async function fetchHourlyForecastForCity(city: string): Promise { + const cached = _hourlyCache.get(city); + if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) { + return cached.data; + } + + const pending = _hourlyRequestCache.get(city); + if (pending) return pending; + + const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`, { + cache: "no-store", + headers: { Accept: "application/json" }, + }) + .then(async (res) => { + if (!res.ok) return null; + return res.json() as Promise; + }) + .then((json) => { + const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly; + if (!json || !hourlySource) return null; + const data: HourlyForecast = { + forecastTodayHigh: json.forecast?.today_high ?? null, + debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null, + localTime: json.local_time || null, + times: hourlySource.times || [], + temps: hourlySource.temps || [], + modelCurves: (json.models_hourly ?? (json as any)?.timeseries?.models_hourly)?.curves || undefined, + runwayPlateHistory: (json as any)?.runway_plate_history || (json.amos as any)?.runway_plate_history || undefined, + amos: json.amos || null, + airportCurrent: json.airport_current || null, + airportPrimary: json.airport_primary || null, + forecastDaily: json.forecast?.daily || [], + multiModelDaily: json.multi_model_daily || {}, + settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined, + metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined, + airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined, + }; + _hourlyCache.set(city, { ts: Date.now(), data }); + return data; + }) + .finally(() => { + _hourlyRequestCache.delete(city); + }); + + _hourlyRequestCache.set(city, request); + return request; +} + function parseRunwayHistoryValue(point: Record) { return validNumber(point.max_temp_c) ?? validNumber(point.temp_c) ?? validNumber(point.temp) ?? validNumber(point.value); } @@ -689,7 +760,8 @@ function buildDailyChartData( const label = formatDailyDateLabel(dateStr); - const debMax = validNumber(dayMultiModel?.deb?.prediction) ?? (dateStr === localDateStr ? validNumber(row?.deb_prediction) : null); + const debMax = validNumber(dayMultiModel?.deb?.prediction) ?? + (dateStr === localDateStr ? validNumber(hourly?.debPrediction) ?? validNumber(row?.deb_prediction) : null); const maxTemp = validNumber(dayForecast?.max_temp); const minTemp = validNumber(dayForecast?.min_temp); @@ -797,7 +869,7 @@ function buildFullDayChartData( const debPath = buildDebBaselinePath( hourly.times, hourly.temps, - row?.deb_prediction, + validNumber(hourly?.debPrediction) ?? row?.deb_prediction, hourly.localTime || row?.local_time, hourly.forecastTodayHigh, ); @@ -852,7 +924,7 @@ function buildFullDayChartData( // ── Fallback ── if (!series.length) { - const fb = validNumber(row?.current_temp) ?? validNumber(row?.deb_prediction) ?? validNumber(row?.target_threshold); + const fb = validNumber(row?.current_temp) ?? validNumber(hourly?.debPrediction) ?? validNumber(row?.deb_prediction) ?? validNumber(row?.target_threshold); if (fb !== null) { series.push({ key: "current", @@ -994,40 +1066,16 @@ export function LiveTemperatureThresholdChart({ setHourly(cached.data); return; } + setHourly(seedHourlyForecastFromRow(row)); let cancelled = false; - fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`, { - cache: "no-store", - headers: { Accept: "application/json" }, - }) - .then(async (res) => { - if (!res.ok) return null; - return res.json() as Promise; - }) - .then((json) => { - const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly; - if (cancelled || !json || !hourlySource) return; - const data: HourlyForecast = { - forecastTodayHigh: json.forecast?.today_high ?? null, - localTime: json.local_time || null, - times: hourlySource.times || [], - temps: hourlySource.temps || [], - modelCurves: (json.models_hourly ?? (json as any)?.timeseries?.models_hourly)?.curves || undefined, - runwayPlateHistory: (json as any)?.runway_plate_history || (json.amos as any)?.runway_plate_history || undefined, - amos: json.amos || null, - airportCurrent: json.airport_current || null, - airportPrimary: json.airport_primary || null, - forecastDaily: json.forecast?.daily || [], - multiModelDaily: json.multi_model_daily || {}, - settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined, - metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined, - airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined, - }; - _hourlyCache.set(city, { ts: Date.now(), data }); + fetchHourlyForecastForCity(city) + .then((data) => { + if (cancelled || !data) return; setHourly(data); }) .catch(() => {}); return () => { cancelled = true; }; - }, [city]); + }, [city, row]); const { data, series } = useMemo(() => { if (timeframe === "3D") { @@ -1111,7 +1159,7 @@ export function LiveTemperatureThresholdChart({ .filter((v): v is number => v !== null); const modelMin = modelValues.length ? Math.min(...modelValues) : (row?.cluster_core_low ?? null); const modelMax = modelValues.length ? Math.max(...modelValues) : (row?.cluster_core_high ?? null); - const debVal = validNumber(row?.deb_prediction) ?? null; + const debVal = validNumber(hourly?.debPrediction) ?? validNumber(row?.deb_prediction) ?? null; const spread = (modelMax !== null && modelMin !== null) ? modelMax - modelMin : null; const spreadLabel = spread === null ? "" : (spread <= 2.0 ? "低分歧" : (spread <= 4.0 ? "中等分歧" : "高分歧")); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index ab9ca71a..465824c0 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -38,4 +38,10 @@ export function runTests() { !chartSource.includes("window.setInterval"), "selected city detail chart should be on-demand and use model-layer cache instead of 60-second polling", ); + assert( + chartSource.includes("_hourlyRequestCache") && + chartSource.includes("seedHourlyForecastFromRow") && + chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"), + "terminal charts should render from row data immediately and dedupe concurrent city detail requests", + ); } diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts index 7da92124..73f1e2d0 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts @@ -283,4 +283,31 @@ export function runTests() { assert(madisSeries, "US MADIS airport-primary observations should render as a dedicated chart series"); assert(madisSeries.label.includes("MADIS"), "US MADIS series should be labeled as NOAA MADIS instead of plain METAR"); assert(madisSeries.values.filter((value: number | null) => value !== null).length >= 2, "MADIS series should keep sub-hourly observations"); + + const shanghaiDebFromDetail = __buildTemperatureChartDataForTest( + { + city: "shanghai", + local_date: "2026-05-26", + local_time: "14:00", + tz_offset_seconds: 8 * 60 * 60, + deb_prediction: 0, + } as any, + { + localTime: "14:00", + times: ["00:00", "12:00", "18:00"], + temps: [24.2, 31.5, 26.5], + debPrediction: 29.3, + } as any, + "1D", + ); + const shanghaiDebSeries = seriesByKey(shanghaiDebFromDetail.series, "hourly_forecast") as any; + const shanghaiDebValues = shanghaiDebSeries.values.filter((value: number | null): value is number => value !== null); + assert( + Math.max(...shanghaiDebValues) === 29.3, + "DEB curve should use full-detail deb.prediction before stale terminal row deb_prediction", + ); + assert( + Math.min(...shanghaiDebValues) > 20, + "DEB curve should not be pulled into an impossible negative range by stale row deb_prediction=0", + ); }