feat: implement LiveTemperatureThresholdChart and associated data processing logic with comprehensive unit tests

This commit is contained in:
2569718930@qq.com
2026-05-26 23:56:42 +08:00
parent b3496bed2d
commit 91ccb061a7
4 changed files with 164 additions and 27 deletions
@@ -151,6 +151,7 @@ export function LiveTemperatureThresholdChart({
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
const [liveTemp, setLiveTemp] = useState<number | null>(null);
const [isHourlyLoading, setIsHourlyLoading] = useState(false);
const hasLoadedHourlyDetailRef = useRef(false);
const lastPatchAtRef = useRef<number>(Date.now());
const lastAppliedPatchRevisionRef = useRef<number>(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;
}
@@ -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");
@@ -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(
{
@@ -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<string, Array<[string, string]>> = {
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<T extends { ts: number }>(
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<number | null | undefined>,
tzOffsetSeconds: number,
localDateStr: string,
bounds: LocalDayBounds | null = null,
) {
const result: Array<number | null> = 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<number | null | undefined> | 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<Record<string, any>>; 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<typeof v> => v !== null);
}).filter((v): v is NonNullable<typeof v> => 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}`,