From 91ccb061a7ab2dd369e5309154a2510488362311 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 26 May 2026 23:56:42 +0800 Subject: [PATCH] feat: implement LiveTemperatureThresholdChart and associated data processing logic with comprehensive unit tests --- .../LiveTemperatureThresholdChart.tsx | 20 +++- .../__tests__/ssePatchArchitecture.test.ts | 11 ++ ...temperatureDefaultVisibilityPolicy.test.ts | 55 +++++++++ .../scan-terminal/temperature-chart-logic.ts | 105 ++++++++++++++---- 4 files changed, 164 insertions(+), 27 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 0a0d365c..295ad878 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -151,6 +151,7 @@ export function LiveTemperatureThresholdChart({ const [userToggledKeys, setUserToggledKeys] = useState>({}); const [liveTemp, setLiveTemp] = useState(null); const [isHourlyLoading, setIsHourlyLoading] = useState(false); + const hasLoadedHourlyDetailRef = useRef(false); const lastPatchAtRef = useRef(Date.now()); const lastAppliedPatchRevisionRef = useRef(0); @@ -165,6 +166,9 @@ export function LiveTemperatureThresholdChart({ setZoomRange(null); setViewMode("auto"); setShowRunwayDetails(true); + setHourly(seedHourlyForecastFromRow(row)); + setIsHourlyLoading(Boolean(city)); + hasLoadedHourlyDetailRef.current = false; lastPatchAtRef.current = Date.now(); lastAppliedPatchRevisionRef.current = 0; }, [city]); @@ -188,13 +192,16 @@ export function LiveTemperatureThresholdChart({ } if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) { + hasLoadedHourlyDetailRef.current = true; setHourly(cached.data); setIsHourlyLoading(false); return; } - setHourly(seedHourlyForecastFromRow(row)); - setIsHourlyLoading(true); + if (!hasLoadedHourlyDetailRef.current) { + setHourly(seedHourlyForecastFromRow(row)); + } + setIsHourlyLoading(!hasLoadedHourlyDetailRef.current); let cancelled = false; // Prioritize active slots, stagger/delay background slots to optimize load performance @@ -204,6 +211,7 @@ export function LiveTemperatureThresholdChart({ fetchHourlyForecastForCity(city, { resolution: targetResolution }) .then((data) => { if (cancelled || !data) return; + hasLoadedHourlyDetailRef.current = true; setHourly(data); }) .catch(() => {}) @@ -230,10 +238,10 @@ export function LiveTemperatureThresholdChart({ useEffect(() => { if (!resyncVersion || !city) return; let cancelled = false; - setIsHourlyLoading(true); fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution }) .then((data) => { if (cancelled || !data) return; + hasLoadedHourlyDetailRef.current = true; setHourly(data); }) .catch(() => {}) @@ -252,11 +260,11 @@ export function LiveTemperatureThresholdChart({ const refreshFullDetail = () => { lastPatchAtRef.current = Date.now(); - setIsHourlyLoading(true); fetchHourlyForecastForCity(city, { ignoreCache: true }) .then((data) => { if (cancelled || !data) return; + hasLoadedHourlyDetailRef.current = true; setHourly(data); }) .catch(() => {}) @@ -325,9 +333,9 @@ export function LiveTemperatureThresholdChart({ const tzOffset = row?.tz_offset_seconds ?? 0; const settlementObs = useMemo(() => { - let obs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset); + let obs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, undefined, row?.local_date || null); if (!obs.length && !hourly?.runwayPlateHistory) { - const mObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs, tzOffset); + const mObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs, tzOffset, undefined, row?.local_date || null); if (mObs.length > 0) { obs = mObs; } diff --git a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts index 3bb0b8a5..9d14b489 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts @@ -104,6 +104,17 @@ export function runTests() { assert(chart.includes("nearestSeriesValue"), "temperature chart tooltip must fall back to nearest non-null value for connected sparse lines"); assert(chart.includes("isHourlyLoading"), "temperature chart must keep a per-panel hourly loading state"); assert(chart.includes("加载图表") && chart.includes("absolute inset-2"), "temperature chart must render an in-chart loading overlay"); + assert(chart.includes("hasLoadedHourlyDetailRef"), "temperature chart must distinguish first load from background refreshes"); + const fallbackRefreshBlock = chart.match(/const refreshFullDetail = \(\) => \{[\s\S]*?\n \};/)?.[0] || ""; + assert( + !fallbackRefreshBlock.includes("setIsHourlyLoading(true)"), + "no-patch fallback refresh should update the chart in the background without showing the loading overlay", + ); + const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, targetResolution\]\);/)?.[0] || ""; + assert( + !resyncBlock.includes("setIsHourlyLoading(true)"), + "SSE replay resync should refresh full detail in the background without showing the loading overlay", + ); assert(chart.includes("viewMode"), "temperature chart must expose a view mode for DEB-peak auto view versus full-day view"); assert(chart.includes("getDebPeakWindowRange"), "temperature chart must derive its default view from the DEB peak window"); assert(chart.includes("nextTargetResolution"), "temperature chart must derive target resolution without setting state on every render"); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts index e710b446..1335c627 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts @@ -331,6 +331,27 @@ export function runTests() { "Istanbul/MGM high label should be weather station", ); + const panamaLabels = __getLiveObservationLabelsForTest( + { + city: "panama city", + airport: "MPMG", + metar_context: { + source: "metar", + station: "MPMG", + station_label: "MPMG METAR", + }, + } as any, + null, + ); + assert( + panamaLabels.runwayHeaderLabel === "机场气象站", + "Panama City/MPMG should not be labeled as runway observations when no runway sensor feed exists", + ); + assert( + panamaLabels.runwayHighLabel === "机场气象站", + "Panama City high label should use airport weather station wording, not runway wording", + ); + const newYorkWithMadis = __buildTemperatureChartDataForTest( { city: "new york", @@ -505,6 +526,40 @@ export function runTests() { "DEB curve should not be pulled into an impossible negative range by stale row deb_prediction=0", ); + const qingdaoFullDay = __buildTemperatureChartDataForTest( + { + city: "qingdao", + local_date: "2026-05-26", + local_time: "23:30", + tz_offset_seconds: 8 * 60 * 60, + deb_prediction: 22, + runway_plate_history: { + "16/34": [ + { time: "2026-05-25T23:30:00+08:00", temp: 23.8 }, + { time: "2026-05-26T00:05:00+08:00", temp: 23.5 }, + { time: "2026-05-26T12:00:00+08:00", temp: 21.6 }, + ], + }, + } as any, + { + localTime: "23:30", + times: ["00:00", "06:00", "12:00", "18:00", "23:00"], + temps: [24, 19, 21.5, 21.5, 20], + debPrediction: 22, + } as any, + "1D", + ); + const qingdaoDayStart = Date.UTC(2026, 4, 26, 0, 0, 0); + const qingdaoDayEnd = Date.UTC(2026, 4, 27, 0, 0, 0); + assert( + qingdaoFullDay.data.every((point) => point.ts >= qingdaoDayStart && point.ts < qingdaoDayEnd), + "Full-day chart should clamp observation history to the selected local_date so DEB does not appear broken after cross-day runway history", + ); + assert( + qingdaoFullDay.data[0]?.ts === qingdaoDayStart, + "Full-day chart should start at local 00:00 when the DEB hourly path has a midnight point", + ); + // ── Runway range band and runway_max test ── const shanghaiWithBand = __buildTemperatureChartDataForTest( { diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index f624634b..3cdcf27e 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -12,6 +12,7 @@ import type { CityPatch } from "@/hooks/use-sse-patches"; const ROLLING_WINDOW_BEFORE_MS = 12 * 60 * 60 * 1000; const ROLLING_WINDOW_AFTER_LIVE_MS = 2 * 60 * 60 * 1000; const ROLLING_WINDOW_AFTER_FORECAST_MS = 8 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; const SETTLEMENT_RUNWAY_PAIRS: Record> = { shanghai: [["17L", "35R"]], @@ -196,6 +197,7 @@ type RunwayHistorySeries = { }; type TemperatureBandPoint = { ts: number; high: number; low: number; avg: number }; +type LocalDayBounds = { start: number; end: number }; const MAX_OBS_POINTS = 1440; const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; @@ -311,6 +313,45 @@ function getCityLocalUtcTimestamp( return null; } +function getLocalDayBounds(localDateStr: string): LocalDayBounds | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(localDateStr); + if (!match) return null; + const start = Date.UTC( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + 0, + 0, + 0, + ); + return Number.isFinite(start) ? { start, end: start + DAY_MS } : null; +} + +function isWithinLocalDay(ts: number | null, bounds: LocalDayBounds | null) { + return ts !== null && Number.isFinite(ts) && (!bounds || (ts >= bounds.start && ts < bounds.end)); +} + +function filterTimelinePointsToLocalDay( + points: T[], + bounds: LocalDayBounds | null, +) { + if (!bounds) return points; + return points.filter((point) => isWithinLocalDay(point.ts, bounds)); +} + +function filterRunwayHistoryToLocalDay( + series: RunwayHistorySeries[], + bounds: LocalDayBounds | null, +) { + if (!bounds) return series; + return series + .map((item) => ({ + ...item, + points: filterTimelinePointsToLocalDay(item.points, bounds), + })) + .filter((item) => item.points.length > 1); +} + function formatTimestamp(ts: number): string { const d = new Date(ts); return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}`; @@ -323,16 +364,21 @@ function normalizeRawObsPoint(point: RawObsPoint): ObsPoint | null { return point; } -function normObs(points: RawObsPoint[] | null | undefined, tzOffsetSeconds: number, limit = MAX_OBS_POINTS) { +function normObs( + points: RawObsPoint[] | null | undefined, + tzOffsetSeconds: number, + limit = MAX_OBS_POINTS, + referenceLocalDate?: string | null, +) { return (points || []) .map(normalizeRawObsPoint) .filter((p): p is ObsPoint => p !== null) .filter((p) => validNumber(p.temp) !== null && p.time) - .map((p) => ({ - ts: getCityLocalUtcTimestamp(p.time, tzOffsetSeconds)!, - value: Number(p.temp), - })) - .filter((p) => p.ts !== null) + .map((p) => { + const ts = getCityLocalUtcTimestamp(p.time, tzOffsetSeconds, referenceLocalDate); + return ts === null ? null : { ts, value: Number(p.temp) }; + }) + .filter((p): p is { ts: number; value: number } => p !== null) .slice(-limit); } @@ -377,9 +423,10 @@ function getObservationDisplayMetrics( settlementPlate?: { maxTemp: number | null } | null, ) { const tzOffset = row?.tz_offset_seconds ?? 0; - const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset); - const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset); - const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset); + const localDateStr = row?.local_date || null; + const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, MAX_OBS_POINTS, localDateStr); + const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset, MAX_OBS_POINTS, localDateStr); + const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset, MAX_OBS_POINTS, localDateStr); const latestSettlement = latestObservationValue(settlementObs); const latestMetar = latestObservationValue(metarObs); const latestMadis = latestObservationValue(madisObs); @@ -627,6 +674,7 @@ function getLiveObservationLabels( const hasRealStationNetwork = weatherStationCities.has(normalizedKey) || /\b(mgm|turkey_mgm|jma_amedas|fmi|knmi|cowin_obs|ims|ncm|aeroweb|madis_hfmetar|singapore_mss)\b/.test(sourceTokens); + const isRunwaySensorCity = runwaySensorCities.has(normalizedKey); const isWeatherStation = !runwaySensorCities.has(normalizedKey) && !isHKO && !isShenzhen && !isTokyo && !isSingapore && !isParis && !isTaipei && hasRealStationNetwork; @@ -638,7 +686,8 @@ function getLiveObservationLabels( : isParis ? "官方机场观测 (15分钟)" : isTaipei ? "CWA (10分钟)" : isWeatherStation ? "气象站实测" - : "跑道实测 (1分钟)"; + : isRunwaySensorCity ? "跑道实测 (1分钟)" + : "机场气象站"; const metarHeaderLabel = (isShenzhen || isHKO) ? "天文台实测 (10分钟)" : "METAR 结算 (30分钟)"; @@ -650,7 +699,8 @@ function getLiveObservationLabels( : isParis ? "官方机场观测" : isTaipei ? "CWA" : isWeatherStation ? "气象站" - : "跑道实测"; + : isRunwaySensorCity ? "跑道实测" + : "机场气象站"; const metarHighLabel = isShenzhen ? "天文台" : isHKO ? "天文台" @@ -1075,10 +1125,12 @@ function valuesForHourlyTimes( values: Array, tzOffsetSeconds: number, localDateStr: string, + bounds: LocalDayBounds | null = null, ) { const result: Array = new Array(size).fill(null); (times || []).forEach((time, index) => { const ts = getCityLocalUtcTimestamp(time, tzOffsetSeconds, localDateStr); + if (!isWithinLocalDay(ts, bounds)) return; if (ts === null) return; const value = validNumber(values[index]); if (value === null) return; @@ -1094,12 +1146,13 @@ function addHourlyTimesToTimeline( values: Array | undefined, tzOffsetSeconds: number, localDateStr: string, + bounds: LocalDayBounds | null = null, ) { if (!times?.length || !values?.length) return; times.forEach((time, index) => { if (validNumber(values[index]) === null) return; const ts = getCityLocalUtcTimestamp(time, tzOffsetSeconds, localDateStr); - if (ts !== null) timeline.add(ts); + if (ts !== null && isWithinLocalDay(ts, bounds)) timeline.add(ts); }); } @@ -1110,11 +1163,21 @@ function buildFullDayChartData( ): { data: Array>; series: EvidenceSeries[] } { const tzOffset = row?.tz_offset_seconds ?? 0; const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); + const localDayBounds = getLocalDayBounds(localDateStr); - const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset); - const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset); - const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset); - const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr); + const settlementObs = filterTimelinePointsToLocalDay( + normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, MAX_OBS_POINTS, localDateStr), + localDayBounds, + ); + const metarObs = filterTimelinePointsToLocalDay( + normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset, MAX_OBS_POINTS, localDateStr), + localDayBounds, + ); + const madisObs = filterTimelinePointsToLocalDay(normObs(hourly?.airportPrimaryTodayObs, tzOffset, MAX_OBS_POINTS, localDateStr), localDayBounds); + const runwayHistorySeries = filterRunwayHistoryToLocalDay( + buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr), + localDayBounds, + ); const settlementCityKey = normalizeCityKey(row?.city); const isShenzhen = settlementCityKey === 'shenzhen'; @@ -1143,7 +1206,7 @@ function buildFullDayChartData( } catch { return null; } - }).filter((v): v is NonNullable => v !== null); + }).filter((v): v is NonNullable => v !== null && isWithinLocalDay(v.ts, localDayBounds)); const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan' || settlementCityKey === 'shenzhen' || (row?.city || '').toLowerCase().includes('hong kong') @@ -1169,11 +1232,11 @@ function buildFullDayChartData( hourly.localTime || row?.local_time, hourly.forecastTodayHigh, ); - addHourlyTimesToTimeline(timelineSet, hourly.times, debPath.debTemps, tzOffset, localDateStr); + addHourlyTimesToTimeline(timelineSet, hourly.times, debPath.debTemps, tzOffset, localDateStr, localDayBounds); } if (hourly?.times?.length && hourly?.modelCurves) { Object.values(hourly.modelCurves).forEach((modelTemps) => { - addHourlyTimesToTimeline(timelineSet, hourly.times, modelTemps, tzOffset, localDateStr); + addHourlyTimesToTimeline(timelineSet, hourly.times, modelTemps, tzOffset, localDateStr, localDayBounds); }); } @@ -1263,7 +1326,7 @@ function buildFullDayChartData( // ── DEB forecast curve ── if (hourly?.times?.length && debPath?.debTemps.length) { - const debVals = valuesForHourlyTimes(n, indexByTs, hourly.times, debPath.debTemps, tzOffset, localDateStr); + const debVals = valuesForHourlyTimes(n, indexByTs, hourly.times, debPath.debTemps, tzOffset, localDateStr, localDayBounds); if (debVals.some((v) => v !== null)) { series.push({ key: "hourly_forecast", @@ -1282,7 +1345,7 @@ function buildFullDayChartData( Object.keys(hourly.modelCurves).forEach((model, idx) => { const modelTemps = hourly.modelCurves![model]; if (!modelTemps?.length) return; - const vals = valuesForHourlyTimes(n, indexByTs, hourly.times, modelTemps, tzOffset, localDateStr); + const vals = valuesForHourlyTimes(n, indexByTs, hourly.times, modelTemps, tzOffset, localDateStr, localDayBounds); if (vals.some((v) => v !== null)) { series.push({ key: `model_curve_${model}`,