拆分图表实时观测和模型缓存

This commit is contained in:
2569718930@qq.com
2026-06-16 22:42:01 +08:00
parent d3e76ecac9
commit 3931d21637
3 changed files with 37 additions and 75 deletions
@@ -38,7 +38,6 @@ import {
readCityDetailBatchDiagnostics,
readHourlyDetailSnapshot,
readHourlyDetailSnapshotAgeMs,
rememberHourlyDetailSnapshot,
selectCompactSecondaryTemp,
selectDisplayRunwayTemp,
selectInitialHourlyForRowChange,
@@ -65,7 +64,6 @@ const PEAK_GLOW_BADGE_CLASS = {
cooling: "border-slate-200 bg-slate-100 text-slate-500",
} as const;
const PROBABILITY_REFRESH_AFTER_PATCH_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
const FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS = 90_000;
const LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback;
const DETAIL_LOAD_BATCH_DELAY_MS = 0;
@@ -684,7 +682,6 @@ export function LiveTemperatureThresholdChart({
const chartVisibilityRef = useRef<HTMLDivElement | null>(null);
const lastPatchAtRef = useRef<number>(Date.now());
const lastAppliedPatchRevisionRef = useRef<number>(0);
const lastProbabilityRefreshAtRef = useRef<number>(0);
const lastForegroundRefreshAtRef = useRef<number>(0);
const lastRowObservationSignatureRef = useRef<string>("");
const localDayRolloverFetchDateRef = useRef<string>("");
@@ -763,7 +760,6 @@ export function LiveTemperatureThresholdChart({
hasLoadedHourlyDetailRef.current = false;
lastPatchAtRef.current = now;
lastAppliedPatchRevisionRef.current = 0;
lastProbabilityRefreshAtRef.current = 0;
lastForegroundRefreshAtRef.current = 0;
lastRowObservationSignatureRef.current = "";
localDayRolloverFetchDateRef.current = "";
@@ -811,12 +807,8 @@ export function LiveTemperatureThresholdChart({
}, [row?.tz_offset_seconds]);
const commitHourlySnapshot = useCallback((buildNext: (previous: HourlyForecast) => HourlyForecast) => {
setHourly((prev) => {
const next = buildNext(prev);
rememberHourlyDetailSnapshot(city, targetResolution, next);
return next;
});
}, [city, targetResolution]);
setHourly((prev) => buildNext(prev));
}, []);
const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => {
if (!data) return;
@@ -1024,46 +1016,19 @@ export function LiveTemperatureThresholdChart({
sseSourceToCollectorLatencySec: latestPatch.delivery?.source_to_collector_latency_sec ?? null,
}));
const hasObservationChange =
tempValue !== null ||
Array.isArray(latestPatch.changes.runway_points) ||
Boolean(latestPatch.changes.amos);
if (!hasObservationChange || !shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
const now = Date.now();
if (now - lastProbabilityRefreshAtRef.current < PROBABILITY_REFRESH_AFTER_PATCH_MS) return;
lastProbabilityRefreshAtRef.current = now;
let cancelled = false;
const refreshProbabilityOverlayAfterPatch = () => {
void runHourlyDetailFetch({
source: "force_refresh",
fetchOptions: { ignoreCache: true },
isCancelled: () => cancelled,
});
};
refreshProbabilityOverlayAfterPatch();
return () => {
cancelled = true;
};
}, [latestPatch, city, targetResolution, compact, isActive, isMaximized, getLatestRowSnapshot, commitHourlySnapshot, runHourlyDetailFetch]);
}, [latestPatch, getLatestRowSnapshot, commitHourlySnapshot]);
useEffect(() => {
if (!resyncVersion || !city) return;
let cancelled = false;
void runHourlyDetailFetch({
source: "force_refresh",
fetchOptions: { ignoreCache: true },
isCancelled: () => cancelled,
onSettled: () => {
setIsHourlyLoading(false);
},
void fetchLiveObservationForCity(city).then((payload) => {
if (cancelled || !payload) return;
applyLiveObservationPayload(payload);
});
return () => {
cancelled = true;
};
}, [resyncVersion, city, runHourlyDetailFetch]);
}, [resyncVersion, city, applyLiveObservationPayload]);
// ── SSE fallback: visible charts merge no-store observations if patches stop. ──
useEffect(() => {
@@ -82,9 +82,9 @@ export async function runTests() {
assert(
chartSource.includes("useLatestPatch") &&
chartSource.includes("latestPatch") &&
chartSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
!chartSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
!chartSource.includes("2 * 60_000"),
"selected city chart should consume SSE patches and keep METAR cadence for heavy probability refreshes instead of a 2-minute forced refresh",
"selected city chart should consume SSE patches without coupling live observations to the model/detail refresh cadence",
);
assert(
chartSource.includes("LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback") &&
@@ -136,15 +136,15 @@ export async function runTests() {
"switching back to the terminal tab should refresh visible chart detail through cached backend data without forcing external sources",
);
assert(
chartSource.includes("fetchOptions: { ignoreCache: true }") &&
chartSource.includes("const mergedHourly = mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, latestRow)") &&
chartSource.includes("return mergedHourly;"),
"visible chart fallback must refresh full city detail at the current chart resolution while preserving newer live observations",
chartSource.includes("fetchLiveObservationForCity") &&
chartSource.includes("mergeObservationPayloadIntoHourly") &&
!chartSource.includes("rememberHourlyDetailSnapshot"),
"visible chart fallback must use the no-store observation endpoint without writing live overlays into full-detail cache",
);
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",
!chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS") &&
!chartSource.includes("refreshProbabilityOverlayAfterPatch"),
"live observation patches must not force-refresh model, DEB, probability, or detail-batch data",
);
assert(
chartLogicSource.includes("HOURLY_FORCE_REFRESH_DEDUP_MS") &&
@@ -370,8 +370,8 @@ export async function runTests() {
);
const rememberSnapshotCalls = chartSource.match(/rememberHourlyDetailSnapshot\(/g) || [];
assert(
rememberSnapshotCalls.length === 1,
"temperature chart should persist hourly snapshots through one local commit helper instead of writing cache snapshots from multiple effects",
rememberSnapshotCalls.length === 0,
"temperature chart component must not persist live-merged snapshots into the full-detail cache",
);
const markDetailDegradedBlock =
/const markDetailDegraded = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/.exec(chartSource)?.[0] || "";
@@ -388,13 +388,13 @@ export async function runTests() {
successfulHourlyDetailBlock.includes("getLatestRowSnapshot") &&
successfulHourlyDetailBlock.includes("commitHourlySnapshot") &&
!/\[row\]/.test(successfulHourlyDetailBlock),
"successful city detail refreshes must read the latest row, persist the merged snapshot, and avoid depending on the row object",
"successful city detail refreshes must read the latest row, merge into chart state, and avoid depending on the row object",
);
assert(
chartSource.includes("commitHourlySnapshot") &&
chartSource.includes("mergeRowObservationIntoHourly") &&
chartSource.includes("mergePatchIntoHourly"),
"live row and SSE patch merges must persist their hourly snapshots through the shared commit helper so returning to terminal restores runway history immediately",
"live row and SSE patch merges must flow through the shared state helper without touching the full-detail cache",
);
const coldDetailFetchBlock =
/useEffect\(\(\) => \{\s*if \(!city\) \{[\s\S]*?scheduleTransientDetailRetry[\s\S]*?runHourlyDetailFetch\(\{[\s\S]*?\n \}, \[([\s\S]*?)\]\);/.exec(chartSource)?.[1] || "";
@@ -189,12 +189,12 @@ export function runTests() {
assert(!chart.includes("function mergePatchIntoHourly"), "LiveTemperatureThresholdChart.tsx must not define SSE patch merge logic inline");
assert(chart.includes("useLatestPatch"), "temperature chart must consume useLatestPatch(city)");
assert(chart.includes("latestPatch"), "temperature chart must react to incoming SSE patches");
assert(chart.includes("useSseResyncVersion"), "temperature chart must resync full detail when SSE replay is incomplete");
assert(chart.includes("useSseResyncVersion"), "temperature chart must resync live observations when SSE replay is incomplete");
assert(chartLogic.includes("runway_points"), "temperature chart must merge v1 runway_points into runway history");
assert(
chart.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
!chart.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
!chart.includes("2 * 60_000"),
"temperature chart must keep METAR cadence for heavy patch-triggered probability refreshes",
"temperature chart must not attach model/detail refresh cadence to live observation patches",
);
assert(
chart.includes("LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback"),
@@ -239,12 +239,18 @@ export function runTests() {
!fallbackRefreshBlock.includes("setIsHourlyLoading(true)"),
"no-patch fallback refresh should merge no-store observation data without refreshing cached detail-batch or showing the loading overlay",
);
const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, runHourlyDetailFetch\]\);/)?.[0] || "";
assert(
resyncBlock.includes("runHourlyDetailFetch") &&
resyncBlock.includes("fetchOptions: { ignoreCache: true }") &&
!chart.includes("rememberHourlyDetailSnapshot"),
"temperature chart must not write row/SSE/observation overlays back into the full-detail cache",
);
const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, applyLiveObservationPayload\]\);/)?.[0] || "";
assert(
resyncBlock.includes("fetchLiveObservationForCity") &&
resyncBlock.includes("applyLiveObservationPayload") &&
!resyncBlock.includes("runHourlyDetailFetch") &&
!resyncBlock.includes("ignoreCache: true") &&
!resyncBlock.includes("setIsHourlyLoading(true)"),
"SSE replay resync should refresh full detail in the background without showing the loading overlay",
"SSE replay resync should refresh no-store observations without touching cached detail-batch or showing the loading overlay",
);
assert(
chart.includes("visibilitychange") &&
@@ -290,19 +296,10 @@ export function runTests() {
"runway charts must request 1-minute detail resolution so historical runway lines match live SSE patch cadence",
);
assert(
chart.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS") &&
chart.includes("lastProbabilityRefreshAtRef") &&
chart.includes("refreshProbabilityOverlayAfterPatch"),
"temperature chart must trigger a throttled background probability refresh after live observation patches",
);
const patchEffectBlock = chart.match(
/useEffect\(\(\) => \{\s*if \(!latestPatch[\s\S]*?refreshProbabilityOverlayAfterPatch\(\);[\s\S]*?\}, \[[^\]]*latestPatch[^\]]*runHourlyDetailFetch[^\]]*\]\);/,
)?.[0] || "";
assert(
patchEffectBlock.includes("refreshProbabilityOverlayAfterPatch") &&
patchEffectBlock.includes("fetchOptions: { ignoreCache: true }") &&
!patchEffectBlock.includes("setIsHourlyLoading(true)"),
"live patch probability refresh must recompute legacy Gaussian in the background without showing a loading overlay",
!chart.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS") &&
!chart.includes("lastProbabilityRefreshAtRef") &&
!chart.includes("refreshProbabilityOverlayAfterPatch"),
"live observation patches must not trigger model/probability/detail refreshes; cached detail-batch stays on its own cadence",
);
assert(!chartCanvas.includes("ResponsiveContainer"), "temperature chart canvas must not mount Recharts through ResponsiveContainer at 0x0");
assert(chartCanvas.includes("ResizeObserver"), "temperature chart canvas must measure its host with ResizeObserver");