Preserve chart models during live updates
This commit is contained in:
@@ -408,6 +408,136 @@ export function runTests() {
|
|||||||
"older automatic detail refreshes must preserve the newer visible chart probability payload",
|
"older automatic detail refreshes must preserve the newer visible chart probability payload",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const richGuangzhouDetail = {
|
||||||
|
...seededGuangzhou,
|
||||||
|
localDate: "2026-06-10",
|
||||||
|
localTime: "12:50",
|
||||||
|
times: ["00:00", "12:00", "18:00"],
|
||||||
|
temps: [25, 31, 28],
|
||||||
|
debPrediction: 32.2,
|
||||||
|
debHourlyPath: {
|
||||||
|
times: ["00:00", "12:00", "18:00"],
|
||||||
|
temps: [25.2, 32.2, 28.4],
|
||||||
|
},
|
||||||
|
modelTimes: ["00:00", "12:00", "18:00"],
|
||||||
|
modelCurves: {
|
||||||
|
GFS: [25, 31.5, 28],
|
||||||
|
ECMWF: [24.8, 32, 28.2],
|
||||||
|
},
|
||||||
|
multiModelDaily: {
|
||||||
|
GFS: { high: 31.5 },
|
||||||
|
ECMWF: { high: 32 },
|
||||||
|
},
|
||||||
|
airportPrimary: { temp: 28.4, obs_time: "12:50", max_so_far: 31 },
|
||||||
|
airportPrimaryTodayObs: [["12:50", 28.4]],
|
||||||
|
} as any;
|
||||||
|
const freshObservationOnlyDetail = {
|
||||||
|
...seededGuangzhou,
|
||||||
|
localDate: "2026-06-10",
|
||||||
|
localTime: "12:55",
|
||||||
|
times: ["00:00", "12:00", "18:00"],
|
||||||
|
temps: [25, 31, 28],
|
||||||
|
debPrediction: null,
|
||||||
|
debHourlyPath: null,
|
||||||
|
modelTimes: undefined,
|
||||||
|
modelCurves: undefined,
|
||||||
|
multiModelDaily: {},
|
||||||
|
airportPrimary: { temp: 28.9, obs_time: "12:55", max_so_far: 31 },
|
||||||
|
airportPrimaryTodayObs: [["12:55", 28.9]],
|
||||||
|
} as any;
|
||||||
|
const mergedFreshObservationWithRichDetail = mergeHourlyWithLiveObservations(
|
||||||
|
freshObservationOnlyDetail,
|
||||||
|
richGuangzhouDetail,
|
||||||
|
guangzhouRow,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
mergedFreshObservationWithRichDetail?.debPrediction === 32.2,
|
||||||
|
"fresh observation-only detail refreshes must preserve the existing DEB prediction",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Object.keys(mergedFreshObservationWithRichDetail?.modelCurves || {}).length === 2,
|
||||||
|
"fresh observation-only detail refreshes must preserve existing multi-model hourly curves",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Object.keys(mergedFreshObservationWithRichDetail?.multiModelDaily || {}).length === 2,
|
||||||
|
"fresh observation-only detail refreshes must preserve existing multi-model daily payloads",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
mergedFreshObservationWithRichDetail?.airportPrimary?.obs_time === "12:55",
|
||||||
|
"fresh observation-only detail refreshes must still update the live observation timestamp",
|
||||||
|
);
|
||||||
|
|
||||||
|
const guangzhouNonUsMadisChart = buildFullDayChartData(
|
||||||
|
{
|
||||||
|
city: "guangzhou",
|
||||||
|
local_date: "2026-06-10",
|
||||||
|
local_time: "12:55",
|
||||||
|
tz_offset_seconds: 8 * 60 * 60,
|
||||||
|
airport: "ZGGG",
|
||||||
|
temp_symbol: "°C",
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
localDate: "2026-06-10",
|
||||||
|
localTime: "12:55",
|
||||||
|
times: ["00:00", "12:00", "18:00"],
|
||||||
|
temps: [25, 31, 28],
|
||||||
|
airportPrimary: {
|
||||||
|
source_code: "madis_hfmetar",
|
||||||
|
source_label: "NOAA MADIS",
|
||||||
|
station_code: "ZGGG",
|
||||||
|
temp: 28.9,
|
||||||
|
obs_time: "2026-06-10T04:55:00Z",
|
||||||
|
},
|
||||||
|
airportPrimaryTodayObs: [["2026-06-10T04:55:00Z", 28.9]],
|
||||||
|
} as any,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const guangzhouNonUsMadisSeries = guangzhouNonUsMadisChart.series.find((item) => item.key === "madis");
|
||||||
|
assert(
|
||||||
|
guangzhouNonUsMadisSeries?.label === "ZGGG METAR",
|
||||||
|
"NOAA MADIS label should be reserved for US airports; non-US airport-primary fallback should use METAR wording",
|
||||||
|
);
|
||||||
|
|
||||||
|
const guangzhouRunwayWithBadMadisChart = buildFullDayChartData(
|
||||||
|
{
|
||||||
|
city: "guangzhou",
|
||||||
|
local_date: "2026-06-10",
|
||||||
|
local_time: "12:55",
|
||||||
|
tz_offset_seconds: 8 * 60 * 60,
|
||||||
|
airport: "ZGGG",
|
||||||
|
temp_symbol: "°C",
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
localDate: "2026-06-10",
|
||||||
|
localTime: "12:55",
|
||||||
|
times: ["00:00", "12:00", "18:00"],
|
||||||
|
temps: [25, 31, 28],
|
||||||
|
airportPrimary: {
|
||||||
|
source_code: "madis_hfmetar",
|
||||||
|
source_label: "NOAA MADIS",
|
||||||
|
station_code: "ZGGG",
|
||||||
|
temp: 28.9,
|
||||||
|
obs_time: "2026-06-10T04:55:00Z",
|
||||||
|
},
|
||||||
|
airportPrimaryTodayObs: [["2026-06-10T04:55:00Z", 28.9]],
|
||||||
|
runwayPlateHistory: {
|
||||||
|
"02L/20R": [
|
||||||
|
{ timestamp: "12:51", temp_c: 28, value: 28 },
|
||||||
|
{ timestamp: "12:55", temp_c: 28.4, value: 28.4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
guangzhouRunwayWithBadMadisChart.series.some((item) => item.key === "runway_02L_20R"),
|
||||||
|
"Guangzhou runway history should render the settlement runway line",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!guangzhouRunwayWithBadMadisChart.series.some((item) => item.key === "madis"),
|
||||||
|
"AMSC runway cities should not show a redundant NOAA MADIS aggregate series when runway observations are present",
|
||||||
|
);
|
||||||
|
|
||||||
const chengduDetail = {
|
const chengduDetail = {
|
||||||
forecastTodayHigh: null,
|
forecastTodayHigh: null,
|
||||||
debPrediction: 31,
|
debPrediction: 31,
|
||||||
|
|||||||
@@ -726,6 +726,26 @@ function canonicalAirportPrimarySourceLabel(hourly: HourlyForecast) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function airportCodeForSeriesLabel(
|
||||||
|
hourly: HourlyForecast,
|
||||||
|
row?: ScanOpportunityRow | null,
|
||||||
|
) {
|
||||||
|
const candidates = [
|
||||||
|
hourly?.airportPrimary?.station_code,
|
||||||
|
(hourly?.airportPrimary as any)?.icao,
|
||||||
|
row?.airport,
|
||||||
|
row?.metar_context?.station,
|
||||||
|
];
|
||||||
|
const code = candidates
|
||||||
|
.map((value) => String(value || "").trim().toUpperCase())
|
||||||
|
.find(Boolean);
|
||||||
|
return code || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUsAirportCode(value: string) {
|
||||||
|
return /^K[A-Z0-9]{3}$/.test(String(value || "").trim().toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
function isGenericAirportPrimaryLabel(label: string) {
|
function isGenericAirportPrimaryLabel(label: string) {
|
||||||
const normalized = label.trim().toLowerCase();
|
const normalized = label.trim().toLowerCase();
|
||||||
return (
|
return (
|
||||||
@@ -736,12 +756,20 @@ function isGenericAirportPrimaryLabel(label: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function airportPrimarySeriesLabel(hourly: HourlyForecast, isHKO: boolean) {
|
function airportPrimarySeriesLabel(
|
||||||
|
hourly: HourlyForecast,
|
||||||
|
isHKO: boolean,
|
||||||
|
row?: ScanOpportunityRow | null,
|
||||||
|
) {
|
||||||
if (isHKO) return "HKO";
|
if (isHKO) return "HKO";
|
||||||
const canonicalLabel = canonicalAirportPrimarySourceLabel(hourly);
|
const canonicalLabel = canonicalAirportPrimarySourceLabel(hourly);
|
||||||
if (canonicalLabel === "MGM") return canonicalLabel;
|
if (canonicalLabel === "MGM") return canonicalLabel;
|
||||||
const payloadLabel = String(hourly?.airportPrimary?.source_label || "").trim();
|
const payloadLabel = String(hourly?.airportPrimary?.source_label || "").trim();
|
||||||
if (payloadLabel && !isGenericAirportPrimaryLabel(payloadLabel)) return payloadLabel;
|
if (payloadLabel && !isGenericAirportPrimaryLabel(payloadLabel)) return payloadLabel;
|
||||||
|
if (canonicalLabel === "NOAA MADIS" && !isUsAirportCode(airportCodeForSeriesLabel(hourly, row))) {
|
||||||
|
const stationCode = airportCodeForSeriesLabel(hourly, row);
|
||||||
|
return stationCode ? `${stationCode} METAR` : "METAR";
|
||||||
|
}
|
||||||
return canonicalLabel || payloadLabel || "NOAA MADIS";
|
return canonicalLabel || payloadLabel || "NOAA MADIS";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1143,6 +1171,39 @@ function hasFullHourlyDetailPayload(hourly: HourlyForecast) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasArrayItems<T>(value: T[] | null | undefined): value is T[] {
|
||||||
|
return Array.isArray(value) && value.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasProbabilityPayload(value: LegacyGaussianProbabilitySource | null | undefined) {
|
||||||
|
const distribution =
|
||||||
|
value?.distribution_all ||
|
||||||
|
value?.distribution ||
|
||||||
|
[];
|
||||||
|
return Boolean(value?.mu != null || distribution.length > 0 || value?.engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferNumber(
|
||||||
|
primary: number | null | undefined,
|
||||||
|
fallback: number | null | undefined,
|
||||||
|
) {
|
||||||
|
return validNumber(primary) ?? validNumber(fallback) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferArray<T>(
|
||||||
|
primary: T[] | null | undefined,
|
||||||
|
fallback: T[] | null | undefined,
|
||||||
|
) {
|
||||||
|
return hasArrayItems(primary) ? primary : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferRecord<T>(
|
||||||
|
primary: Record<string, T> | null | undefined,
|
||||||
|
fallback: Record<string, T> | null | undefined,
|
||||||
|
) {
|
||||||
|
return hasRecordEntries(primary) ? primary : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function latestRawObservationRank(
|
function latestRawObservationRank(
|
||||||
points: RawObsPoint[] | null | undefined,
|
points: RawObsPoint[] | null | undefined,
|
||||||
row: ScanOpportunityRow | null,
|
row: ScanOpportunityRow | null,
|
||||||
@@ -1372,6 +1433,7 @@ function mergeHourlyWithLiveObservations(
|
|||||||
if (!live) return base;
|
if (!live) return base;
|
||||||
if (hourlyLocalDatesConflict(base, live, row)) return base;
|
if (hourlyLocalDatesConflict(base, live, row)) return base;
|
||||||
const detailSource = shouldKeepLiveHourlyDetailPayload(base, live, row) ? live : base;
|
const detailSource = shouldKeepLiveHourlyDetailPayload(base, live, row) ? live : base;
|
||||||
|
const forecastFallback = detailSource === base ? live : base;
|
||||||
const localDate = detailSource.localDate || base.localDate || live.localDate || row?.local_date || null;
|
const localDate = detailSource.localDate || base.localDate || live.localDate || row?.local_date || null;
|
||||||
const runwayPlateHistory = mergeRunwayPlateHistory(base.runwayPlateHistory, live.runwayPlateHistory);
|
const runwayPlateHistory = mergeRunwayPlateHistory(base.runwayPlateHistory, live.runwayPlateHistory);
|
||||||
const amos = runwayPlateHistory
|
const amos = runwayPlateHistory
|
||||||
@@ -1384,6 +1446,19 @@ function mergeHourlyWithLiveObservations(
|
|||||||
...detailSource,
|
...detailSource,
|
||||||
localDate,
|
localDate,
|
||||||
localTime: live.localTime || base.localTime,
|
localTime: live.localTime || base.localTime,
|
||||||
|
forecastTodayHigh: preferNumber(detailSource.forecastTodayHigh, forecastFallback.forecastTodayHigh),
|
||||||
|
debPrediction: preferNumber(detailSource.debPrediction, forecastFallback.debPrediction),
|
||||||
|
debQuality: hasRecordEntries(detailSource.debQuality) ? detailSource.debQuality : forecastFallback.debQuality,
|
||||||
|
debHourlyPath: detailSource.debHourlyPath || forecastFallback.debHourlyPath || null,
|
||||||
|
times: preferArray(detailSource.times, forecastFallback.times) || [],
|
||||||
|
temps: preferArray(detailSource.temps, forecastFallback.temps) || [],
|
||||||
|
modelTimes: preferArray(detailSource.modelTimes, forecastFallback.modelTimes) || undefined,
|
||||||
|
modelCurves: preferRecord(detailSource.modelCurves, forecastFallback.modelCurves) || undefined,
|
||||||
|
forecastDaily: preferArray(detailSource.forecastDaily, forecastFallback.forecastDaily) || [],
|
||||||
|
multiModelDaily: preferRecord(detailSource.multiModelDaily, forecastFallback.multiModelDaily) || {},
|
||||||
|
probabilities: hasProbabilityPayload(detailSource.probabilities)
|
||||||
|
? detailSource.probabilities
|
||||||
|
: forecastFallback.probabilities || null,
|
||||||
runwayPlateHistory,
|
runwayPlateHistory,
|
||||||
amos,
|
amos,
|
||||||
airportCurrent: mergeAirportCondition(base.airportCurrent, live.airportCurrent, row, localDate),
|
airportCurrent: mergeAirportCondition(base.airportCurrent, live.airportCurrent, row, localDate),
|
||||||
@@ -2435,9 +2510,14 @@ function buildFullDayChartData(
|
|||||||
const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan'
|
const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan'
|
||||||
|| settlementCityKey === 'shenzhen' || (row?.city || '').toLowerCase().includes('hong kong')
|
|| settlementCityKey === 'shenzhen' || (row?.city || '').toLowerCase().includes('hong kong')
|
||||||
|| (row?.city || '').toLowerCase().includes('lau fau shan');
|
|| (row?.city || '').toLowerCase().includes('lau fau shan');
|
||||||
|
const airportPrimarySourceText = [
|
||||||
|
(hourly?.airportPrimary as any)?.source,
|
||||||
|
hourly?.airportPrimary?.source_code,
|
||||||
|
hourly?.airportPrimary?.source_label,
|
||||||
|
].map((value) => String(value || "").toLowerCase()).join(" ");
|
||||||
const isAmscSource =
|
const isAmscSource =
|
||||||
(hourly?.airportPrimary as any)?.source === "amsc_awos" ||
|
airportPrimarySourceText.includes("amsc") ||
|
||||||
String(hourly?.airportPrimary?.source_label || "").toLowerCase().includes("amsc");
|
(AMSC_RUNWAY_CITIES.has(settlementCityKey) && runwayHistorySeries.length > 0);
|
||||||
const isKoreanAmosSource =
|
const isKoreanAmosSource =
|
||||||
(settlementCityKey === "seoul" || settlementCityKey === "busan") &&
|
(settlementCityKey === "seoul" || settlementCityKey === "busan") &&
|
||||||
(
|
(
|
||||||
@@ -2555,7 +2635,7 @@ function buildFullDayChartData(
|
|||||||
if (madisVals.some((v) => v !== null)) {
|
if (madisVals.some((v) => v !== null)) {
|
||||||
series.push({
|
series.push({
|
||||||
key: "madis",
|
key: "madis",
|
||||||
label: airportPrimarySeriesLabel(hourly, isHKO),
|
label: airportPrimarySeriesLabel(hourly, isHKO, row),
|
||||||
source: isHKO ? "HKO" : (hourly?.airportPrimary?.station_code || row?.airport || "MADIS"),
|
source: isHKO ? "HKO" : (hourly?.airportPrimary?.station_code || row?.airport || "MADIS"),
|
||||||
color: "#0284c7",
|
color: "#0284c7",
|
||||||
dashed: isHKO ? true : false,
|
dashed: isHKO ? true : false,
|
||||||
|
|||||||
Reference in New Issue
Block a user