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}
|
payload={props.payload as ReadonlyArray<{ payload?: Record<string, any> }> | undefined}
|
||||||
data={zoomedData}
|
data={zoomedData}
|
||||||
series={activeSeries}
|
series={activeSeries}
|
||||||
|
probabilityOverlay={probabilityOverlay}
|
||||||
tempSymbol={tempSymbol}
|
tempSymbol={tempSymbol}
|
||||||
|
isEn={isEn}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
formatter={(value: unknown) => {
|
formatter={(value: unknown) => {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"use client";
|
"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 TooltipSeries = Pick<EvidenceSeries, "key" | "label" | "color">;
|
||||||
type TooltipRow = TooltipSeries & { value: number };
|
type TooltipRow = TooltipSeries & { value: number };
|
||||||
|
type TooltipProbabilityRow = { key: string; label: string; value: string; color: string };
|
||||||
|
|
||||||
function isRunwayTooltipSeries(seriesKey: string) {
|
function isRunwayTooltipSeries(seriesKey: string) {
|
||||||
return seriesKey.startsWith("runway_");
|
return seriesKey.startsWith("runway_");
|
||||||
@@ -36,38 +41,66 @@ export function TemperatureTooltipContent({
|
|||||||
payload,
|
payload,
|
||||||
data,
|
data,
|
||||||
series,
|
series,
|
||||||
|
probabilityOverlay,
|
||||||
tempSymbol = "°C",
|
tempSymbol = "°C",
|
||||||
|
isEn = false,
|
||||||
}: {
|
}: {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
label?: string | number;
|
label?: string | number;
|
||||||
payload?: ReadonlyArray<{ payload?: Record<string, any> }>;
|
payload?: ReadonlyArray<{ payload?: Record<string, any> }>;
|
||||||
data: Array<Record<string, any>>;
|
data: Array<Record<string, any>>;
|
||||||
series: TooltipSeries[];
|
series: TooltipSeries[];
|
||||||
|
probabilityOverlay?: ProbabilityOverlay | null;
|
||||||
tempSymbol?: string;
|
tempSymbol?: string;
|
||||||
|
isEn?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (!active || !payload?.length || !series.length) return null;
|
if (!active) return null;
|
||||||
const activePoint = payload[0]?.payload || {};
|
const activePoint = payload?.[0]?.payload || findTooltipPointByLabel(data, label) || {};
|
||||||
const rows = buildTooltipRows(activePoint, data, series);
|
const rows = series.length ? buildTooltipRows(activePoint, data, series) : [];
|
||||||
if (!rows.length) return null;
|
const probabilityRows = buildTooltipProbabilityRows(probabilityOverlay, tempSymbol, isEn);
|
||||||
|
if (!rows.length && !probabilityRows.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded border border-slate-200 bg-white px-2.5 py-2 text-[11px] shadow-lg">
|
<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="mb-1 font-mono text-slate-500">{label}</div>
|
||||||
<div className="grid gap-1">
|
{rows.length > 0 && (
|
||||||
{rows.slice(0, 8).map((item) => (
|
<div className="grid gap-1">
|
||||||
<div key={item.key} className="flex min-w-[140px] items-center justify-between gap-4">
|
{rows.slice(0, 8).map((item) => (
|
||||||
<span className="inline-flex items-center gap-1.5 text-slate-700">
|
<div key={item.key} className="flex min-w-[140px] items-center justify-between gap-4">
|
||||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
<span className="inline-flex items-center gap-1.5 text-slate-700">
|
||||||
<span className="font-semibold">{item.label}</span>
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||||
</span>
|
<span className="font-semibold">{item.label}</span>
|
||||||
<strong className="font-mono text-slate-900">{item.value.toFixed(2)}{tempSymbol}</strong>
|
</span>
|
||||||
</div>
|
<strong className="font-mono text-slate-900">{item.value.toFixed(2)}{tempSymbol}</strong>
|
||||||
))}
|
</div>
|
||||||
</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>
|
</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(
|
function buildTooltipRows(
|
||||||
activePoint: Record<string, any>,
|
activePoint: Record<string, any>,
|
||||||
data: Array<Record<string, any>>,
|
data: Array<Record<string, any>>,
|
||||||
@@ -85,4 +118,43 @@ function buildTooltipRows(
|
|||||||
.filter((item): item is TooltipRow => item !== null);
|
.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 __buildTemperatureTooltipRowsForTest = buildTooltipRows;
|
||||||
|
export const __buildTemperatureTooltipProbabilityRowsForTest = buildTooltipProbabilityRows;
|
||||||
|
|||||||
@@ -99,6 +99,16 @@ export async function runTests() {
|
|||||||
chartLogicSource.includes("recentlyRefreshed.data"),
|
chartLogicSource.includes("recentlyRefreshed.data"),
|
||||||
"forced chart detail refreshes should reuse a very recent full-detail payload instead of refetching repeatedly",
|
"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(
|
assert(
|
||||||
__shouldPollLiveChartForTest({ city: "shanghai", compact: true, isActive: false, isMaximized: false }) === true,
|
__shouldPollLiveChartForTest({ city: "shanghai", compact: true, isActive: false, isMaximized: false }) === true,
|
||||||
"compact grid slots are visible charts and should run the no-patch fallback guard",
|
"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] || "";
|
const fetchHourlyBlock = chartLogicSource.match(/async function fetchHourlyForecastForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction shouldPollLiveChart/)?.[0] || "";
|
||||||
assert(
|
assert(
|
||||||
fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam)") &&
|
fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam, forceRefresh)") &&
|
||||||
!fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"),
|
!fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"),
|
||||||
"first-paint and background city detail refreshes should both enter the batch queue without falling back to single requests",
|
"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) {
|
function assert(condition: unknown, message: string) {
|
||||||
if (!condition) throw new Error(message);
|
if (!condition) throw new Error(message);
|
||||||
@@ -48,4 +51,33 @@ export function runTests() {
|
|||||||
rows.some((row) => row.key === "gfs" && row.value === 24.2),
|
rows.some((row) => row.key === "gfs" && row.value === 24.2),
|
||||||
"non-runway sparse series should keep nearest-value fallback",
|
"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>;
|
cities: Set<string>;
|
||||||
waiters: Map<string, CityDetailBatchWaiter[]>;
|
waiters: Map<string, CityDetailBatchWaiter[]>;
|
||||||
timer: ReturnType<typeof setTimeout> | null;
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
|
resolution: string;
|
||||||
|
forceRefresh: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CITY_DETAIL_BATCH_WINDOW_MS = 100;
|
const CITY_DETAIL_BATCH_WINDOW_MS = 100;
|
||||||
@@ -1033,14 +1035,25 @@ function primeCityDetailCache(
|
|||||||
return data;
|
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) => {
|
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>(),
|
cities: new Set<string>(),
|
||||||
waiters: new Map<string, CityDetailBatchWaiter[]>(),
|
waiters: new Map<string, CityDetailBatchWaiter[]>(),
|
||||||
timer: null,
|
timer: null,
|
||||||
|
resolution,
|
||||||
|
forceRefresh,
|
||||||
};
|
};
|
||||||
_cityDetailBatchQueues.set(resolution, queue);
|
_cityDetailBatchQueues.set(queueKey, queue);
|
||||||
|
|
||||||
const cityWaiters = queue.waiters.get(city) || [];
|
const cityWaiters = queue.waiters.get(city) || [];
|
||||||
cityWaiters.push({ resolve, reject });
|
cityWaiters.push({ resolve, reject });
|
||||||
@@ -1048,10 +1061,10 @@ function queueCityDetailBatch(city: string, resolution: string): Promise<HourlyF
|
|||||||
queue.cities.add(city);
|
queue.cities.add(city);
|
||||||
|
|
||||||
if (queue.timer === null) {
|
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) {
|
if (queue.cities.size >= CITY_DETAIL_BATCH_MAX_CITIES) {
|
||||||
flushCityDetailBatch(resolution);
|
flushCityDetailBatch(queueKey);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1087,10 +1100,10 @@ function resolveCityDetailFromBatch(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flushCityDetailBatch(resolution: string) {
|
async function flushCityDetailBatch(queueKey: string) {
|
||||||
const queue = _cityDetailBatchQueues.get(resolution);
|
const queue = _cityDetailBatchQueues.get(queueKey);
|
||||||
if (!queue) return;
|
if (!queue) return;
|
||||||
_cityDetailBatchQueues.delete(resolution);
|
_cityDetailBatchQueues.delete(queueKey);
|
||||||
if (queue.timer !== null) {
|
if (queue.timer !== null) {
|
||||||
clearTimeout(queue.timer);
|
clearTimeout(queue.timer);
|
||||||
queue.timer = null;
|
queue.timer = null;
|
||||||
@@ -1100,7 +1113,11 @@ async function flushCityDetailBatch(resolution: string) {
|
|||||||
if (!cities.length) return;
|
if (!cities.length) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await fetchCityDetailBatchWithTimeout(cities, resolution);
|
const payload = await fetchCityDetailBatchWithTimeout(
|
||||||
|
cities,
|
||||||
|
queue.resolution,
|
||||||
|
queue.forceRefresh,
|
||||||
|
);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
resolveAllBatchWaitersAsNull(cities, queue);
|
resolveAllBatchWaitersAsNull(cities, queue);
|
||||||
return;
|
return;
|
||||||
@@ -1115,7 +1132,7 @@ async function flushCityDetailBatch(resolution: string) {
|
|||||||
cities.map(async (city) => {
|
cities.map(async (city) => {
|
||||||
const waiters = queue.waiters.get(city);
|
const waiters = queue.waiters.get(city);
|
||||||
const detail = resolveCityDetailFromBatch(details, city);
|
const detail = resolveCityDetailFromBatch(details, city);
|
||||||
const data = primeCityDetailCache(city, resolution, detail);
|
const data = primeCityDetailCache(city, queue.resolution, detail);
|
||||||
if (data) {
|
if (data) {
|
||||||
resolveBatchWaiters(waiters, data);
|
resolveBatchWaiters(waiters, data);
|
||||||
return;
|
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 controller = new AbortController();
|
||||||
const timeoutId = globalThis.setTimeout(() => controller.abort(), HOURLY_DETAIL_REQUEST_TIMEOUT_MS);
|
const timeoutId = globalThis.setTimeout(() => controller.abort(), HOURLY_DETAIL_REQUEST_TIMEOUT_MS);
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
cities: cities.join(","),
|
cities: cities.join(","),
|
||||||
depth: "full",
|
depth: "full",
|
||||||
force_refresh: "false",
|
force_refresh: forceRefresh ? "true" : "false",
|
||||||
limit: String(Math.max(cities.length, CITY_DETAIL_BATCH_MAX_CITIES)),
|
limit: String(Math.max(cities.length, CITY_DETAIL_BATCH_MAX_CITIES)),
|
||||||
resolution,
|
resolution,
|
||||||
scope: "chart",
|
scope: "chart",
|
||||||
@@ -1170,8 +1191,9 @@ async function fetchHourlyForecastForCity(
|
|||||||
): Promise<HourlyForecast> {
|
): Promise<HourlyForecast> {
|
||||||
const resParam = options.resolution || "10m";
|
const resParam = options.resolution || "10m";
|
||||||
const cacheKey = `${city}:${resParam}`;
|
const cacheKey = `${city}:${resParam}`;
|
||||||
|
const forceRefresh = Boolean(options.ignoreCache);
|
||||||
|
|
||||||
if (!options.ignoreCache) {
|
if (!forceRefresh) {
|
||||||
const cached = readHourlyCacheEntry(cacheKey);
|
const cached = readHourlyCacheEntry(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached.data;
|
return cached.data;
|
||||||
@@ -1189,7 +1211,7 @@ async function fetchHourlyForecastForCity(
|
|||||||
const pending = _hourlyRequestCache.get(requestKey);
|
const pending = _hourlyRequestCache.get(requestKey);
|
||||||
if (pending) return pending;
|
if (pending) return pending;
|
||||||
|
|
||||||
const request = queueCityDetailBatch(city, resParam)
|
const request = queueCityDetailBatch(city, resParam, forceRefresh)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
_hourlyRequestCache.delete(requestKey);
|
_hourlyRequestCache.delete(requestKey);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user