feat: implement LiveTemperatureThresholdChart and add visibility and refresh policy unit tests
This commit is contained in:
@@ -32,10 +32,10 @@ const ROLLING_WINDOW_AFTER_FORECAST_MS = 8 * 60 * 60 * 1000;
|
||||
|
||||
const SETTLEMENT_RUNWAY_PAIRS: Record<string, Array<[string, string]>> = {
|
||||
shanghai: [["17L", "35R"]],
|
||||
beijing: [["01", "19"]],
|
||||
beijing: [["19", "01"]],
|
||||
guangzhou: [["02L", "20R"]],
|
||||
chengdu: [["02L", "20R"]],
|
||||
chongqing: [["02L", "20R"]],
|
||||
chongqing: [["20R", "02L"]],
|
||||
wuhan: [["04", "22"]],
|
||||
seoul: [["15R", "33L"]],
|
||||
};
|
||||
@@ -196,6 +196,7 @@ const FULL_DAY_SLOT_MINUTES = 30;
|
||||
const FULL_DAY_SLOTS = 48;
|
||||
const SLOT_INTERVAL_MS = FULL_DAY_SLOT_MINUTES * 60 * 1000;
|
||||
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
|
||||
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
|
||||
const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
|
||||
|
||||
function validNumber(value: unknown): number | null {
|
||||
@@ -375,6 +376,7 @@ function runwayLabelFromPair(rawPair: unknown, index: number) {
|
||||
|
||||
type HourlyForecast = {
|
||||
forecastTodayHigh?: number | null;
|
||||
debPrediction?: number | null;
|
||||
localTime?: string | null;
|
||||
times: string[];
|
||||
temps: Array<number | null>;
|
||||
@@ -390,6 +392,75 @@ type HourlyForecast = {
|
||||
airportPrimaryTodayObs?: RawObsPoint[];
|
||||
} | null;
|
||||
|
||||
function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForecast {
|
||||
if (!row) return null;
|
||||
return {
|
||||
forecastTodayHigh: null,
|
||||
debPrediction: validNumber(row.deb_prediction),
|
||||
localTime: row.local_time || null,
|
||||
times: [],
|
||||
temps: [],
|
||||
modelCurves: undefined,
|
||||
runwayPlateHistory: (row as any)?.runway_plate_history || undefined,
|
||||
amos: null,
|
||||
airportCurrent: null,
|
||||
airportPrimary: null,
|
||||
forecastDaily: [],
|
||||
multiModelDaily: {},
|
||||
settlementTodayObs: row.settlement_today_obs || row.metar_context?.settlement_today_obs || undefined,
|
||||
metarTodayObs: row.metar_today_obs || row.metar_context?.today_obs || row.metar_recent_obs || row.metar_context?.recent_obs || undefined,
|
||||
airportPrimaryTodayObs: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchHourlyForecastForCity(city: string): Promise<HourlyForecast> {
|
||||
const cached = _hourlyCache.get(city);
|
||||
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const pending = _hourlyRequestCache.get(city);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`, {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<CityDetail>;
|
||||
})
|
||||
.then((json) => {
|
||||
const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly;
|
||||
if (!json || !hourlySource) return null;
|
||||
const data: HourlyForecast = {
|
||||
forecastTodayHigh: json.forecast?.today_high ?? null,
|
||||
debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null,
|
||||
localTime: json.local_time || null,
|
||||
times: hourlySource.times || [],
|
||||
temps: hourlySource.temps || [],
|
||||
modelCurves: (json.models_hourly ?? (json as any)?.timeseries?.models_hourly)?.curves || undefined,
|
||||
runwayPlateHistory: (json as any)?.runway_plate_history || (json.amos as any)?.runway_plate_history || undefined,
|
||||
amos: json.amos || null,
|
||||
airportCurrent: json.airport_current || null,
|
||||
airportPrimary: json.airport_primary || null,
|
||||
forecastDaily: json.forecast?.daily || [],
|
||||
multiModelDaily: json.multi_model_daily || {},
|
||||
settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined,
|
||||
metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined,
|
||||
airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined,
|
||||
};
|
||||
_hourlyCache.set(city, { ts: Date.now(), data });
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
_hourlyRequestCache.delete(city);
|
||||
});
|
||||
|
||||
_hourlyRequestCache.set(city, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function parseRunwayHistoryValue(point: Record<string, unknown>) {
|
||||
return validNumber(point.max_temp_c) ?? validNumber(point.temp_c) ?? validNumber(point.temp) ?? validNumber(point.value);
|
||||
}
|
||||
@@ -689,7 +760,8 @@ function buildDailyChartData(
|
||||
|
||||
const label = formatDailyDateLabel(dateStr);
|
||||
|
||||
const debMax = validNumber(dayMultiModel?.deb?.prediction) ?? (dateStr === localDateStr ? validNumber(row?.deb_prediction) : null);
|
||||
const debMax = validNumber(dayMultiModel?.deb?.prediction) ??
|
||||
(dateStr === localDateStr ? validNumber(hourly?.debPrediction) ?? validNumber(row?.deb_prediction) : null);
|
||||
const maxTemp = validNumber(dayForecast?.max_temp);
|
||||
const minTemp = validNumber(dayForecast?.min_temp);
|
||||
|
||||
@@ -797,7 +869,7 @@ function buildFullDayChartData(
|
||||
const debPath = buildDebBaselinePath(
|
||||
hourly.times,
|
||||
hourly.temps,
|
||||
row?.deb_prediction,
|
||||
validNumber(hourly?.debPrediction) ?? row?.deb_prediction,
|
||||
hourly.localTime || row?.local_time,
|
||||
hourly.forecastTodayHigh,
|
||||
);
|
||||
@@ -852,7 +924,7 @@ function buildFullDayChartData(
|
||||
|
||||
// ── Fallback ──
|
||||
if (!series.length) {
|
||||
const fb = validNumber(row?.current_temp) ?? validNumber(row?.deb_prediction) ?? validNumber(row?.target_threshold);
|
||||
const fb = validNumber(row?.current_temp) ?? validNumber(hourly?.debPrediction) ?? validNumber(row?.deb_prediction) ?? validNumber(row?.target_threshold);
|
||||
if (fb !== null) {
|
||||
series.push({
|
||||
key: "current",
|
||||
@@ -994,40 +1066,16 @@ export function LiveTemperatureThresholdChart({
|
||||
setHourly(cached.data);
|
||||
return;
|
||||
}
|
||||
setHourly(seedHourlyForecastFromRow(row));
|
||||
let cancelled = false;
|
||||
fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`, {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<CityDetail>;
|
||||
})
|
||||
.then((json) => {
|
||||
const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly;
|
||||
if (cancelled || !json || !hourlySource) return;
|
||||
const data: HourlyForecast = {
|
||||
forecastTodayHigh: json.forecast?.today_high ?? null,
|
||||
localTime: json.local_time || null,
|
||||
times: hourlySource.times || [],
|
||||
temps: hourlySource.temps || [],
|
||||
modelCurves: (json.models_hourly ?? (json as any)?.timeseries?.models_hourly)?.curves || undefined,
|
||||
runwayPlateHistory: (json as any)?.runway_plate_history || (json.amos as any)?.runway_plate_history || undefined,
|
||||
amos: json.amos || null,
|
||||
airportCurrent: json.airport_current || null,
|
||||
airportPrimary: json.airport_primary || null,
|
||||
forecastDaily: json.forecast?.daily || [],
|
||||
multiModelDaily: json.multi_model_daily || {},
|
||||
settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined,
|
||||
metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined,
|
||||
airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined,
|
||||
};
|
||||
_hourlyCache.set(city, { ts: Date.now(), data });
|
||||
fetchHourlyForecastForCity(city)
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
setHourly(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [city]);
|
||||
}, [city, row]);
|
||||
|
||||
const { data, series } = useMemo(() => {
|
||||
if (timeframe === "3D") {
|
||||
@@ -1111,7 +1159,7 @@ export function LiveTemperatureThresholdChart({
|
||||
.filter((v): v is number => v !== null);
|
||||
const modelMin = modelValues.length ? Math.min(...modelValues) : (row?.cluster_core_low ?? null);
|
||||
const modelMax = modelValues.length ? Math.max(...modelValues) : (row?.cluster_core_high ?? null);
|
||||
const debVal = validNumber(row?.deb_prediction) ?? null;
|
||||
const debVal = validNumber(hourly?.debPrediction) ?? validNumber(row?.deb_prediction) ?? null;
|
||||
|
||||
const spread = (modelMax !== null && modelMin !== null) ? modelMax - modelMin : null;
|
||||
const spreadLabel = spread === null ? "" : (spread <= 2.0 ? "低分歧" : (spread <= 4.0 ? "中等分歧" : "高分歧"));
|
||||
|
||||
@@ -38,4 +38,10 @@ export function runTests() {
|
||||
!chartSource.includes("window.setInterval"),
|
||||
"selected city detail chart should be on-demand and use model-layer cache instead of 60-second polling",
|
||||
);
|
||||
assert(
|
||||
chartSource.includes("_hourlyRequestCache") &&
|
||||
chartSource.includes("seedHourlyForecastFromRow") &&
|
||||
chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"),
|
||||
"terminal charts should render from row data immediately and dedupe concurrent city detail requests",
|
||||
);
|
||||
}
|
||||
|
||||
+27
@@ -283,4 +283,31 @@ export function runTests() {
|
||||
assert(madisSeries, "US MADIS airport-primary observations should render as a dedicated chart series");
|
||||
assert(madisSeries.label.includes("MADIS"), "US MADIS series should be labeled as NOAA MADIS instead of plain METAR");
|
||||
assert(madisSeries.values.filter((value: number | null) => value !== null).length >= 2, "MADIS series should keep sub-hourly observations");
|
||||
|
||||
const shanghaiDebFromDetail = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "shanghai",
|
||||
local_date: "2026-05-26",
|
||||
local_time: "14:00",
|
||||
tz_offset_seconds: 8 * 60 * 60,
|
||||
deb_prediction: 0,
|
||||
} as any,
|
||||
{
|
||||
localTime: "14:00",
|
||||
times: ["00:00", "12:00", "18:00"],
|
||||
temps: [24.2, 31.5, 26.5],
|
||||
debPrediction: 29.3,
|
||||
} as any,
|
||||
"1D",
|
||||
);
|
||||
const shanghaiDebSeries = seriesByKey(shanghaiDebFromDetail.series, "hourly_forecast") as any;
|
||||
const shanghaiDebValues = shanghaiDebSeries.values.filter((value: number | null): value is number => value !== null);
|
||||
assert(
|
||||
Math.max(...shanghaiDebValues) === 29.3,
|
||||
"DEB curve should use full-detail deb.prediction before stale terminal row deb_prediction",
|
||||
);
|
||||
assert(
|
||||
Math.min(...shanghaiDebValues) > 20,
|
||||
"DEB curve should not be pulled into an impossible negative range by stale row deb_prediction=0",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user