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]>> = {
|
const SETTLEMENT_RUNWAY_PAIRS: Record<string, Array<[string, string]>> = {
|
||||||
shanghai: [["17L", "35R"]],
|
shanghai: [["17L", "35R"]],
|
||||||
beijing: [["01", "19"]],
|
beijing: [["19", "01"]],
|
||||||
guangzhou: [["02L", "20R"]],
|
guangzhou: [["02L", "20R"]],
|
||||||
chengdu: [["02L", "20R"]],
|
chengdu: [["02L", "20R"]],
|
||||||
chongqing: [["02L", "20R"]],
|
chongqing: [["20R", "02L"]],
|
||||||
wuhan: [["04", "22"]],
|
wuhan: [["04", "22"]],
|
||||||
seoul: [["15R", "33L"]],
|
seoul: [["15R", "33L"]],
|
||||||
};
|
};
|
||||||
@@ -196,6 +196,7 @@ const FULL_DAY_SLOT_MINUTES = 30;
|
|||||||
const FULL_DAY_SLOTS = 48;
|
const FULL_DAY_SLOTS = 48;
|
||||||
const SLOT_INTERVAL_MS = FULL_DAY_SLOT_MINUTES * 60 * 1000;
|
const SLOT_INTERVAL_MS = FULL_DAY_SLOT_MINUTES * 60 * 1000;
|
||||||
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
|
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"];
|
const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
|
||||||
|
|
||||||
function validNumber(value: unknown): number | null {
|
function validNumber(value: unknown): number | null {
|
||||||
@@ -375,6 +376,7 @@ function runwayLabelFromPair(rawPair: unknown, index: number) {
|
|||||||
|
|
||||||
type HourlyForecast = {
|
type HourlyForecast = {
|
||||||
forecastTodayHigh?: number | null;
|
forecastTodayHigh?: number | null;
|
||||||
|
debPrediction?: number | null;
|
||||||
localTime?: string | null;
|
localTime?: string | null;
|
||||||
times: string[];
|
times: string[];
|
||||||
temps: Array<number | null>;
|
temps: Array<number | null>;
|
||||||
@@ -390,6 +392,75 @@ type HourlyForecast = {
|
|||||||
airportPrimaryTodayObs?: RawObsPoint[];
|
airportPrimaryTodayObs?: RawObsPoint[];
|
||||||
} | null;
|
} | 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>) {
|
function parseRunwayHistoryValue(point: Record<string, unknown>) {
|
||||||
return validNumber(point.max_temp_c) ?? validNumber(point.temp_c) ?? validNumber(point.temp) ?? validNumber(point.value);
|
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 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 maxTemp = validNumber(dayForecast?.max_temp);
|
||||||
const minTemp = validNumber(dayForecast?.min_temp);
|
const minTemp = validNumber(dayForecast?.min_temp);
|
||||||
|
|
||||||
@@ -797,7 +869,7 @@ function buildFullDayChartData(
|
|||||||
const debPath = buildDebBaselinePath(
|
const debPath = buildDebBaselinePath(
|
||||||
hourly.times,
|
hourly.times,
|
||||||
hourly.temps,
|
hourly.temps,
|
||||||
row?.deb_prediction,
|
validNumber(hourly?.debPrediction) ?? row?.deb_prediction,
|
||||||
hourly.localTime || row?.local_time,
|
hourly.localTime || row?.local_time,
|
||||||
hourly.forecastTodayHigh,
|
hourly.forecastTodayHigh,
|
||||||
);
|
);
|
||||||
@@ -852,7 +924,7 @@ function buildFullDayChartData(
|
|||||||
|
|
||||||
// ── Fallback ──
|
// ── Fallback ──
|
||||||
if (!series.length) {
|
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) {
|
if (fb !== null) {
|
||||||
series.push({
|
series.push({
|
||||||
key: "current",
|
key: "current",
|
||||||
@@ -994,40 +1066,16 @@ export function LiveTemperatureThresholdChart({
|
|||||||
setHourly(cached.data);
|
setHourly(cached.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setHourly(seedHourlyForecastFromRow(row));
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`, {
|
fetchHourlyForecastForCity(city)
|
||||||
cache: "no-store",
|
.then((data) => {
|
||||||
headers: { Accept: "application/json" },
|
if (cancelled || !data) return;
|
||||||
})
|
|
||||||
.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 });
|
|
||||||
setHourly(data);
|
setHourly(data);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [city]);
|
}, [city, row]);
|
||||||
|
|
||||||
const { data, series } = useMemo(() => {
|
const { data, series } = useMemo(() => {
|
||||||
if (timeframe === "3D") {
|
if (timeframe === "3D") {
|
||||||
@@ -1111,7 +1159,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
.filter((v): v is number => v !== null);
|
.filter((v): v is number => v !== null);
|
||||||
const modelMin = modelValues.length ? Math.min(...modelValues) : (row?.cluster_core_low ?? 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 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 spread = (modelMax !== null && modelMin !== null) ? modelMax - modelMin : null;
|
||||||
const spreadLabel = spread === null ? "" : (spread <= 2.0 ? "低分歧" : (spread <= 4.0 ? "中等分歧" : "高分歧"));
|
const spreadLabel = spread === null ? "" : (spread <= 2.0 ? "低分歧" : (spread <= 4.0 ? "中等分歧" : "高分歧"));
|
||||||
|
|||||||
@@ -38,4 +38,10 @@ export function runTests() {
|
|||||||
!chartSource.includes("window.setInterval"),
|
!chartSource.includes("window.setInterval"),
|
||||||
"selected city detail chart should be on-demand and use model-layer cache instead of 60-second polling",
|
"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, "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.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");
|
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