diff --git a/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx b/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx index ba7fc513..7989a042 100644 --- a/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx +++ b/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx @@ -378,7 +378,9 @@ function TemperatureChartCanvasComponent({ payload={props.payload as ReadonlyArray<{ payload?: Record }> | undefined} data={zoomedData} series={activeSeries} + probabilityOverlay={probabilityOverlay} tempSymbol={tempSymbol} + isEn={isEn} /> )} formatter={(value: unknown) => { diff --git a/frontend/components/dashboard/scan-terminal/TemperatureTooltipContent.tsx b/frontend/components/dashboard/scan-terminal/TemperatureTooltipContent.tsx index a2bc0fee..cc333262 100644 --- a/frontend/components/dashboard/scan-terminal/TemperatureTooltipContent.tsx +++ b/frontend/components/dashboard/scan-terminal/TemperatureTooltipContent.tsx @@ -1,9 +1,14 @@ "use client"; -import { validNumber, type EvidenceSeries } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; +import { + validNumber, + type EvidenceSeries, + type ProbabilityOverlay, +} from "@/components/dashboard/scan-terminal/temperature-chart-logic"; type TooltipSeries = Pick; type TooltipRow = TooltipSeries & { value: number }; +type TooltipProbabilityRow = { key: string; label: string; value: string; color: string }; function isRunwayTooltipSeries(seriesKey: string) { return seriesKey.startsWith("runway_"); @@ -36,38 +41,66 @@ export function TemperatureTooltipContent({ payload, data, series, + probabilityOverlay, tempSymbol = "°C", + isEn = false, }: { active?: boolean; label?: string | number; payload?: ReadonlyArray<{ payload?: Record }>; data: Array>; series: TooltipSeries[]; + probabilityOverlay?: ProbabilityOverlay | null; tempSymbol?: string; + isEn?: boolean; }) { - if (!active || !payload?.length || !series.length) return null; - const activePoint = payload[0]?.payload || {}; - const rows = buildTooltipRows(activePoint, data, series); - if (!rows.length) return null; + if (!active) return null; + const activePoint = payload?.[0]?.payload || findTooltipPointByLabel(data, label) || {}; + const rows = series.length ? buildTooltipRows(activePoint, data, series) : []; + const probabilityRows = buildTooltipProbabilityRows(probabilityOverlay, tempSymbol, isEn); + if (!rows.length && !probabilityRows.length) return null; return (
{label}
-
- {rows.slice(0, 8).map((item) => ( -
- - - {item.label} - - {item.value.toFixed(2)}{tempSymbol} -
- ))} -
+ {rows.length > 0 && ( +
+ {rows.slice(0, 8).map((item) => ( +
+ + + {item.label} + + {item.value.toFixed(2)}{tempSymbol} +
+ ))} +
+ )} + {probabilityRows.length > 0 && ( +
0 ? "mt-1.5 grid gap-1 border-t border-slate-100 pt-1.5" : "grid gap-1"}> + {probabilityRows.slice(0, 8).map((item) => ( +
+ + + {item.label} + + {item.value} +
+ ))} +
+ )}
); } +function findTooltipPointByLabel( + data: Array>, + label?: string | number, +) { + if (label === undefined || label === null) return null; + return data.find((point) => String(point.label) === String(label)) || null; +} + function buildTooltipRows( activePoint: Record, data: Array>, @@ -85,4 +118,43 @@ function buildTooltipRows( .filter((item): item is TooltipRow => item !== null); } +function formatProbabilityTemp(value: number, tempSymbol: string) { + return `${value.toFixed(1)}${tempSymbol}`; +} + +function buildTooltipProbabilityRows( + probabilityOverlay: ProbabilityOverlay | null | undefined, + tempSymbol: string, + isEn: boolean, +): TooltipProbabilityRow[] { + if (!probabilityOverlay) return []; + + const rows: TooltipProbabilityRow[] = []; + if (probabilityOverlay.muLine) { + rows.push({ + key: "gaussian_mu", + label: isEn ? "Gaussian μ" : "高斯 μ", + value: formatProbabilityTemp(probabilityOverlay.muLine.value, tempSymbol), + color: "#7c3aed", + }); + } + + probabilityOverlay.bands + .slice() + .sort((left, right) => right.probability - left.probability) + .forEach((band) => { + const range = `${band.lower.toFixed(1)}-${band.upper.toFixed(1)}${tempSymbol}`; + const probability = `${Math.round(band.probability * 100)}%`; + rows.push({ + key: band.key, + label: isEn ? "Probability band" : "概率温度带", + value: `${range} ${probability}`, + color: "#8b5cf6", + }); + }); + + return rows; +} + export const __buildTemperatureTooltipRowsForTest = buildTooltipRows; +export const __buildTemperatureTooltipProbabilityRowsForTest = buildTooltipProbabilityRows; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index 3513f3cb..e883bf8c 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -99,6 +99,16 @@ export async function runTests() { chartLogicSource.includes("recentlyRefreshed.data"), "forced chart detail refreshes should reuse a very recent full-detail payload instead of refetching repeatedly", ); + assert( + chartLogicSource.includes("forceRefresh: boolean") && + chartLogicSource.includes('force_refresh: forceRefresh ? "true" : "false"'), + "ignoreCache chart detail refreshes must send force_refresh=true so fresh METAR observations bypass proxy and backend chart caches", + ); + assert( + chartLogicSource.includes("cityDetailBatchQueueKey") && + chartLogicSource.includes('forceRefresh ? "force" : "cached"'), + "forced and cached chart detail requests must use separate batch queues so a normal first-paint request cannot downgrade a live refresh", + ); assert( __shouldPollLiveChartForTest({ city: "shanghai", compact: true, isActive: false, isMaximized: false }) === true, "compact grid slots are visible charts and should run the no-patch fallback guard", @@ -148,7 +158,7 @@ export async function runTests() { ); const fetchHourlyBlock = chartLogicSource.match(/async function fetchHourlyForecastForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction shouldPollLiveChart/)?.[0] || ""; assert( - fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam)") && + fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam, forceRefresh)") && !fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"), "first-paint and background city detail refreshes should both enter the batch queue without falling back to single requests", ); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureTooltipContent.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureTooltipContent.test.ts index a80584d4..2006e36b 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureTooltipContent.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureTooltipContent.test.ts @@ -1,4 +1,7 @@ -import { __buildTemperatureTooltipRowsForTest } from "@/components/dashboard/scan-terminal/TemperatureTooltipContent"; +import { + __buildTemperatureTooltipProbabilityRowsForTest, + __buildTemperatureTooltipRowsForTest, +} from "@/components/dashboard/scan-terminal/TemperatureTooltipContent"; function assert(condition: unknown, message: string) { if (!condition) throw new Error(message); @@ -48,4 +51,33 @@ export function runTests() { rows.some((row) => row.key === "gfs" && row.value === 24.2), "non-runway sparse series should keep nearest-value fallback", ); + + const probabilityRows = __buildTemperatureTooltipProbabilityRowsForTest( + { + engine: "legacy", + muLine: { value: 27.4, label: "Gaussian μ 27.4°C" }, + bands: [ + { + key: "legacy_probability_27_0", + value: 27, + lower: 26.5, + upper: 27.5, + probability: 0.42, + label: "27°C 42%", + opacity: 0.13, + }, + ], + }, + "°C", + true, + ); + + assert( + probabilityRows.some((row) => row.key === "gaussian_mu" && row.value === "27.4°C"), + "tooltip should expose the Gaussian μ line when the purple probability overlay is present", + ); + assert( + probabilityRows.some((row) => row.value.includes("26.5-27.5°C") && row.value.includes("42%")), + "tooltip should expose purple probability-band range and probability values", + ); } diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index a7b1ee2f..a47dabb9 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -986,6 +986,8 @@ type CityDetailBatchQueue = { cities: Set; waiters: Map; timer: ReturnType | null; + resolution: string; + forceRefresh: boolean; }; const CITY_DETAIL_BATCH_WINDOW_MS = 100; @@ -1033,14 +1035,25 @@ function primeCityDetailCache( return data; } -function queueCityDetailBatch(city: string, resolution: string): Promise { +function cityDetailBatchQueueKey(resolution: string, forceRefresh: boolean) { + return `${resolution}:${forceRefresh ? "force" : "cached"}`; +} + +function queueCityDetailBatch( + city: string, + resolution: string, + forceRefresh: boolean, +): Promise { return new Promise((resolve, reject) => { - const queue = _cityDetailBatchQueues.get(resolution) || { + const queueKey = cityDetailBatchQueueKey(resolution, forceRefresh); + const queue = _cityDetailBatchQueues.get(queueKey) || { cities: new Set(), waiters: new Map(), timer: null, + resolution, + forceRefresh, }; - _cityDetailBatchQueues.set(resolution, queue); + _cityDetailBatchQueues.set(queueKey, queue); const cityWaiters = queue.waiters.get(city) || []; cityWaiters.push({ resolve, reject }); @@ -1048,10 +1061,10 @@ function queueCityDetailBatch(city: string, resolution: string): Promise flushCityDetailBatch(resolution), CITY_DETAIL_BATCH_WINDOW_MS); + queue.timer = setTimeout(() => flushCityDetailBatch(queueKey), CITY_DETAIL_BATCH_WINDOW_MS); } if (queue.cities.size >= CITY_DETAIL_BATCH_MAX_CITIES) { - flushCityDetailBatch(resolution); + flushCityDetailBatch(queueKey); } }); } @@ -1087,10 +1100,10 @@ function resolveCityDetailFromBatch( return undefined; } -async function flushCityDetailBatch(resolution: string) { - const queue = _cityDetailBatchQueues.get(resolution); +async function flushCityDetailBatch(queueKey: string) { + const queue = _cityDetailBatchQueues.get(queueKey); if (!queue) return; - _cityDetailBatchQueues.delete(resolution); + _cityDetailBatchQueues.delete(queueKey); if (queue.timer !== null) { clearTimeout(queue.timer); queue.timer = null; @@ -1100,7 +1113,11 @@ async function flushCityDetailBatch(resolution: string) { if (!cities.length) return; try { - const payload = await fetchCityDetailBatchWithTimeout(cities, resolution); + const payload = await fetchCityDetailBatchWithTimeout( + cities, + queue.resolution, + queue.forceRefresh, + ); if (!payload) { resolveAllBatchWaitersAsNull(cities, queue); return; @@ -1115,7 +1132,7 @@ async function flushCityDetailBatch(resolution: string) { cities.map(async (city) => { const waiters = queue.waiters.get(city); const detail = resolveCityDetailFromBatch(details, city); - const data = primeCityDetailCache(city, resolution, detail); + const data = primeCityDetailCache(city, queue.resolution, detail); if (data) { resolveBatchWaiters(waiters, data); return; @@ -1141,13 +1158,17 @@ function resolveAllBatchWaitersAsNull( }); } -function fetchCityDetailBatchWithTimeout(cities: string[], resolution: string) { +function fetchCityDetailBatchWithTimeout( + cities: string[], + resolution: string, + forceRefresh: boolean, +) { const controller = new AbortController(); const timeoutId = globalThis.setTimeout(() => controller.abort(), HOURLY_DETAIL_REQUEST_TIMEOUT_MS); const params = new URLSearchParams({ cities: cities.join(","), depth: "full", - force_refresh: "false", + force_refresh: forceRefresh ? "true" : "false", limit: String(Math.max(cities.length, CITY_DETAIL_BATCH_MAX_CITIES)), resolution, scope: "chart", @@ -1170,8 +1191,9 @@ async function fetchHourlyForecastForCity( ): Promise { const resParam = options.resolution || "10m"; const cacheKey = `${city}:${resParam}`; + const forceRefresh = Boolean(options.ignoreCache); - if (!options.ignoreCache) { + if (!forceRefresh) { const cached = readHourlyCacheEntry(cacheKey); if (cached) { return cached.data; @@ -1189,7 +1211,7 @@ async function fetchHourlyForecastForCity( const pending = _hourlyRequestCache.get(requestKey); if (pending) return pending; - const request = queueCityDetailBatch(city, resParam) + const request = queueCityDetailBatch(city, resParam, forceRefresh) .finally(() => { _hourlyRequestCache.delete(requestKey); });