Preserve terminal chart detail during refresh

This commit is contained in:
2569718930@qq.com
2026-06-14 20:11:08 +08:00
parent 9cb794655b
commit c0a5912246
3 changed files with 170 additions and 2 deletions
@@ -37,6 +37,7 @@ import {
readSessionCache,
selectCompactSecondaryTemp,
selectDisplayRunwayTemp,
selectInitialHourlyForRowChange,
seedHourlyForecastFromRow,
shouldPollLiveChart,
validNumber,
@@ -528,6 +529,30 @@ function getInitialDetailLoadDelayMs({
return DEFERRED_DETAIL_LOAD_DELAY_MS + deferredWave * DEFERRED_DETAIL_LOAD_WAVE_STEP_MS;
}
function readCachedHourlyForInitialRow(
city: string,
preferredResolution: string,
): HourlyForecast {
const cityKey = String(city || "").toLowerCase().trim();
if (!cityKey) return null;
const resolutions = [
preferredResolution,
preferredResolution === "1m" ? "10m" : "1m",
].filter((value, index, list) => value && list.indexOf(value) === index);
for (const resolution of resolutions) {
const cacheKey = `${cityKey}:${resolution}`;
const memoryEntry = _hourlyCache.get(cacheKey);
if (memoryEntry?.data) return memoryEntry.data;
const sessionEntry = readSessionCache(cacheKey, { allowStale: true });
if (sessionEntry?.data) {
_hourlyCache.set(cacheKey, sessionEntry);
return sessionEntry.data;
}
}
return null;
}
// ── Main component ─────────────────────────────────────────────────────
export function LiveTemperatureThresholdChart({
@@ -575,6 +600,7 @@ export function LiveTemperatureThresholdChart({
createChartFreshnessState(),
);
const hasLoadedHourlyDetailRef = useRef(false);
const hourlyCityRef = useRef<string>("");
const chartVisibilityRef = useRef<HTMLDivElement | null>(null);
const lastPatchAtRef = useRef<number>(Date.now());
const lastAppliedPatchRevisionRef = useRef<number>(0);
@@ -624,12 +650,23 @@ export function LiveTemperatureThresholdChart({
useEffect(() => {
const now = Date.now();
const previousCity = hourlyCityRef.current;
hourlyCityRef.current = city;
const nextTargetResolution = prefersHighFrequencyRunwayResolution(row, null) ? "1m" : "10m";
const cachedHourly = readCachedHourlyForInitialRow(city, nextTargetResolution);
setUserToggledKeys({});
setZoomRange(null);
setViewMode("full");
setShowRunwayDetails(true);
setTargetResolution(prefersHighFrequencyRunwayResolution(row, null) ? "1m" : "10m");
setHourly(seedHourlyForecastFromRow(row));
setTargetResolution(nextTargetResolution);
setHourly((prev) =>
selectInitialHourlyForRowChange({
cachedHourly,
previousCity,
previousHourly: prev,
row,
}),
);
setLiveTemp(null);
setIsHourlyLoading(Boolean(city) && detailLoadDelayMs === 0);
setDetailError(null);
@@ -6,6 +6,7 @@ import {
mergeHourlyWithLiveObservations,
mergePatchIntoHourly,
mergeRowObservationIntoHourly,
selectInitialHourlyForRowChange,
seedHourlyForecastFromRow,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
@@ -474,4 +475,107 @@ export function runTests() {
chengduMerged?.airportCurrent?.temp === 28,
"stale previous-day scan rows must not replace current-date detail airport conditions",
);
const shenzhenRow = {
city: "shenzhen",
local_date: "2026-06-10",
local_time: "12:03",
current_temp: 31.2,
current_max_so_far: 31.2,
temp_symbol: "°C",
tz_offset_seconds: 8 * 3600,
} as any;
const shenzhenFullDetail = {
...seedHourlyForecastFromRow(shenzhenRow),
localDate: "2026-06-10",
localTime: "12:00",
times: ["10:00", "11:00", "12:00"],
temps: [28, 30, 31],
modelTimes: ["10:00", "11:00", "12:00"],
modelCurves: {
ECMWF: [28.2, 30.1, 31.1],
GFS: [27.9, 29.8, 30.9],
},
debPrediction: 33,
debHourlyPath: {
source: "deb_hourly_consensus",
times: ["10:00", "11:00", "12:00"],
temps: [29, 31, 33],
},
multiModelDaily: {
"2026-06-10": {
deb: { prediction: 33 },
models: { ECMWF: 33.2, GFS: 32.7 },
},
},
} as any;
const shenzhenUpdatedRow = {
...shenzhenRow,
local_time: "12:07",
current_temp: 31.6,
current_max_so_far: 31.6,
} as any;
const shenzhenInitialAfterReturn = selectInitialHourlyForRowChange({
previousCity: "shenzhen",
previousHourly: shenzhenFullDetail,
row: shenzhenUpdatedRow,
});
assert(
shenzhenInitialAfterReturn?.modelCurves?.ECMWF?.length === 3,
"returning to terminal must keep existing multi-model curves while the detail refresh is pending",
);
assert(
shenzhenInitialAfterReturn?.debHourlyPath?.temps?.includes(33),
"returning to terminal must keep the existing DEB hourly path while the detail refresh is pending",
);
assert(
shenzhenInitialAfterReturn?.airportCurrent?.temp === 31.6,
"returning to terminal should still merge the newest scan-row observation into the preserved detail",
);
const hongKongCachedDetail = {
...seedHourlyForecastFromRow({ ...shenzhenRow, city: "hongkong" } as any),
localDate: "2026-06-10",
times: ["10:00", "11:00"],
temps: [29.5, 30.2],
modelCurves: { ECMWF: [29.8, 30.5] },
debPrediction: 31,
debHourlyPath: {
source: "deb_hourly_consensus",
times: ["10:00", "11:00"],
temps: [30, 31],
},
} as any;
const hongKongRow = {
...shenzhenRow,
city: "hongkong",
local_time: "12:10",
current_temp: 30.7,
} as any;
const hongKongInitialAfterSelect = selectInitialHourlyForRowChange({
cachedHourly: hongKongCachedDetail,
previousCity: "shenzhen",
previousHourly: shenzhenFullDetail,
row: hongKongRow,
});
assert(
hongKongInitialAfterSelect?.modelCurves?.ECMWF?.length === 2 &&
hongKongInitialAfterSelect?.debHourlyPath?.temps?.includes(31),
"selecting a city with cached detail must render cached multi-model and DEB immediately instead of falling back to an empty row seed",
);
assert(
!hongKongInitialAfterSelect?.modelCurves?.GFS,
"selecting a different city must not leak the previous city's model curves",
);
const uncachedNewCityInitial = selectInitialHourlyForRowChange({
previousCity: "shenzhen",
previousHourly: shenzhenFullDetail,
row: { ...shenzhenRow, city: "tokyo" } as any,
});
assert(
!uncachedNewCityInitial?.modelCurves?.ECMWF &&
!uncachedNewCityInitial?.debHourlyPath,
"selecting an uncached different city must not show another city's model and DEB curves",
);
}
@@ -1406,6 +1406,32 @@ function mergeRowObservationIntoHourly(
return mergeHourlyWithLiveObservations(prev, seeded, row);
}
function selectInitialHourlyForRowChange({
cachedHourly,
previousCity,
previousHourly,
row,
}: {
cachedHourly?: HourlyForecast;
previousCity?: string | null;
previousHourly?: HourlyForecast;
row: ScanOpportunityRow | null;
}): HourlyForecast {
const seeded = seedHourlyForecastFromRow(row);
const nextCity = normalizeCityKey(row?.city);
if (!nextCity) return seeded;
if (cachedHourly) {
return mergeHourlyWithLiveObservations(cachedHourly, seeded, row);
}
if (previousHourly && normalizeCityKey(previousCity) === nextCity) {
return mergeHourlyWithLiveObservations(previousHourly, seeded, row);
}
return seeded;
}
type HourlyForecastFetchOptions = {
bypassLocalCache?: boolean;
ignoreCache?: boolean;
@@ -2980,6 +3006,7 @@ export {
readSessionCache,
selectCompactSecondaryTemp,
selectDisplayRunwayTemp,
selectInitialHourlyForRowChange,
seedHourlyForecastFromRow,
seriesStats,
shouldPollLiveChart,