fix: improve chart tooltip and metar refresh
This commit is contained in:
@@ -378,7 +378,9 @@ function TemperatureChartCanvasComponent({
|
||||
payload={props.payload as ReadonlyArray<{ payload?: Record<string, any> }> | undefined}
|
||||
data={zoomedData}
|
||||
series={activeSeries}
|
||||
probabilityOverlay={probabilityOverlay}
|
||||
tempSymbol={tempSymbol}
|
||||
isEn={isEn}
|
||||
/>
|
||||
)}
|
||||
formatter={(value: unknown) => {
|
||||
|
||||
@@ -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<EvidenceSeries, "key" | "label" | "color">;
|
||||
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<string, any> }>;
|
||||
data: Array<Record<string, any>>;
|
||||
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 (
|
||||
<div className="rounded border border-slate-200 bg-white px-2.5 py-2 text-[11px] shadow-lg">
|
||||
<div className="mb-1 font-mono text-slate-500">{label}</div>
|
||||
<div className="grid gap-1">
|
||||
{rows.slice(0, 8).map((item) => (
|
||||
<div key={item.key} className="flex min-w-[140px] items-center justify-between gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 text-slate-700">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="font-semibold">{item.label}</span>
|
||||
</span>
|
||||
<strong className="font-mono text-slate-900">{item.value.toFixed(2)}{tempSymbol}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{rows.length > 0 && (
|
||||
<div className="grid gap-1">
|
||||
{rows.slice(0, 8).map((item) => (
|
||||
<div key={item.key} className="flex min-w-[140px] items-center justify-between gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 text-slate-700">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="font-semibold">{item.label}</span>
|
||||
</span>
|
||||
<strong className="font-mono text-slate-900">{item.value.toFixed(2)}{tempSymbol}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{probabilityRows.length > 0 && (
|
||||
<div className={rows.length > 0 ? "mt-1.5 grid gap-1 border-t border-slate-100 pt-1.5" : "grid gap-1"}>
|
||||
{probabilityRows.slice(0, 8).map((item) => (
|
||||
<div key={item.key} className="flex min-w-[160px] items-center justify-between gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 text-violet-700">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="font-semibold">{item.label}</span>
|
||||
</span>
|
||||
<strong className="font-mono text-violet-900">{item.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function findTooltipPointByLabel(
|
||||
data: Array<Record<string, any>>,
|
||||
label?: string | number,
|
||||
) {
|
||||
if (label === undefined || label === null) return null;
|
||||
return data.find((point) => String(point.label) === String(label)) || null;
|
||||
}
|
||||
|
||||
function buildTooltipRows(
|
||||
activePoint: Record<string, any>,
|
||||
data: Array<Record<string, any>>,
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
+33
-1
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -986,6 +986,8 @@ type CityDetailBatchQueue = {
|
||||
cities: Set<string>;
|
||||
waiters: Map<string, CityDetailBatchWaiter[]>;
|
||||
timer: ReturnType<typeof setTimeout> | 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<HourlyForecast> {
|
||||
function cityDetailBatchQueueKey(resolution: string, forceRefresh: boolean) {
|
||||
return `${resolution}:${forceRefresh ? "force" : "cached"}`;
|
||||
}
|
||||
|
||||
function queueCityDetailBatch(
|
||||
city: string,
|
||||
resolution: string,
|
||||
forceRefresh: boolean,
|
||||
): Promise<HourlyForecast> {
|
||||
return new Promise<HourlyForecast>((resolve, reject) => {
|
||||
const queue = _cityDetailBatchQueues.get(resolution) || {
|
||||
const queueKey = cityDetailBatchQueueKey(resolution, forceRefresh);
|
||||
const queue = _cityDetailBatchQueues.get(queueKey) || {
|
||||
cities: new Set<string>(),
|
||||
waiters: new Map<string, CityDetailBatchWaiter[]>(),
|
||||
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<HourlyF
|
||||
queue.cities.add(city);
|
||||
|
||||
if (queue.timer === null) {
|
||||
queue.timer = setTimeout(() => 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<HourlyForecast> {
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user