修复图表预测曲线和机场观测标签
This commit is contained in:
@@ -159,6 +159,70 @@ export function runTests() {
|
|||||||
`multi-model curves must use models_hourly.times instead of hourly.times; got ${independentModelPeak?.label}`,
|
`multi-model curves must use models_hourly.times instead of hourly.times; got ${independentModelPeak?.label}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const floatingIsoModelTimelineChart = buildFullDayChartData(
|
||||||
|
{
|
||||||
|
city: "paris",
|
||||||
|
local_date: "2026-06-14",
|
||||||
|
local_time: "12:00",
|
||||||
|
temp_symbol: "°C",
|
||||||
|
tz_offset_seconds: 2 * 3600,
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
forecastTodayHigh: 24,
|
||||||
|
debPrediction: 24,
|
||||||
|
localDate: "2026-06-14",
|
||||||
|
localTime: "12:00",
|
||||||
|
times: ["00:00", "12:00", "23:00"],
|
||||||
|
temps: [18, 21, 16],
|
||||||
|
modelTimes: Array.from({ length: 24 }, (_, hour) => `2026-06-14T${String(hour).padStart(2, "0")}:00`),
|
||||||
|
modelCurves: {
|
||||||
|
ECMWF: Array.from({ length: 24 }, (_, hour) => hour),
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const floatingIsoModelLatePoint = floatingIsoModelTimelineChart.data.find(
|
||||||
|
(point) => point.label === "23:00:00" && point.model_curve_ECMWF === 23,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
floatingIsoModelLatePoint,
|
||||||
|
"floating ISO model times without an explicit timezone must stay on the city-local clock so late-day forecast points do not disappear",
|
||||||
|
);
|
||||||
|
|
||||||
|
const staleDetailWithCurrentRowChart = buildFullDayChartData(
|
||||||
|
{
|
||||||
|
city: "paris",
|
||||||
|
local_date: "2026-06-16",
|
||||||
|
local_time: "14:59",
|
||||||
|
temp_symbol: "°C",
|
||||||
|
tz_offset_seconds: 2 * 3600,
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
forecastTodayHigh: 24,
|
||||||
|
debPrediction: 24,
|
||||||
|
localDate: "2026-06-14",
|
||||||
|
localTime: "11:00",
|
||||||
|
times: ["00:00", "12:00", "23:00"],
|
||||||
|
temps: [18, 21, 16],
|
||||||
|
modelTimes: Array.from({ length: 72 }, (_, index) => {
|
||||||
|
const date = index < 24 ? "2026-06-14" : index < 48 ? "2026-06-15" : "2026-06-16";
|
||||||
|
const hour = index % 24;
|
||||||
|
return `${date}T${String(hour).padStart(2, "0")}:00`;
|
||||||
|
}),
|
||||||
|
modelCurves: {
|
||||||
|
ECMWF: Array.from({ length: 72 }, (_, index) => index),
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const currentDayModelLatePoint = staleDetailWithCurrentRowChart.data.find(
|
||||||
|
(point) => point.label === "23:00:00" && point.model_curve_ECMWF === 71,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
currentDayModelLatePoint,
|
||||||
|
"chart should use the current scan row local date when cached detail localDate is older so today's model curve is drawn through 23:00",
|
||||||
|
);
|
||||||
|
|
||||||
const correctedDetailChart = getTemperatureChartData(
|
const correctedDetailChart = getTemperatureChartData(
|
||||||
{
|
{
|
||||||
name: "shanghai",
|
name: "shanghai",
|
||||||
@@ -571,6 +635,51 @@ export function runTests() {
|
|||||||
"non-US airport-primary fallback without explicit source metadata must not default to NOAA MADIS",
|
"non-US airport-primary fallback without explicit source metadata must not default to NOAA MADIS",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const parisAirportDisplayNameChart = buildFullDayChartData(
|
||||||
|
{
|
||||||
|
city: "paris",
|
||||||
|
local_date: "2026-06-16",
|
||||||
|
local_time: "14:59",
|
||||||
|
tz_offset_seconds: 2 * 60 * 60,
|
||||||
|
airport: "Paris-Le Bourget 机场",
|
||||||
|
metar_context: {
|
||||||
|
station: "LFPB",
|
||||||
|
station_label: "Paris-Le Bourget Airport",
|
||||||
|
},
|
||||||
|
temp_symbol: "°C",
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
localDate: "2026-06-16",
|
||||||
|
localTime: "14:59",
|
||||||
|
times: ["00:00", "12:00", "18:00"],
|
||||||
|
temps: [14, 20, 18],
|
||||||
|
settlementStationCode: "LFPB",
|
||||||
|
settlementStationLabel: "Paris-Le Bourget Airport",
|
||||||
|
airportPrimary: {
|
||||||
|
temp: 20.0,
|
||||||
|
obs_time: "2026-06-16T12:59:00Z",
|
||||||
|
},
|
||||||
|
airportPrimaryTodayObs: [
|
||||||
|
["2026-06-16T10:00:00Z", 18],
|
||||||
|
["2026-06-16T12:00:00Z", 20],
|
||||||
|
],
|
||||||
|
metarTodayObs: [
|
||||||
|
["2026-06-16T10:30:00Z", 18],
|
||||||
|
["2026-06-16T12:30:00Z", 20],
|
||||||
|
],
|
||||||
|
} as any,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const parisAirportDisplayNameSeries = parisAirportDisplayNameChart.series.find((item) => item.key === "madis");
|
||||||
|
assert(
|
||||||
|
parisAirportDisplayNameSeries?.label === "LFPB METAR",
|
||||||
|
`airport-primary fallback must prefer station code over display name; got ${parisAirportDisplayNameSeries?.label}`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!parisAirportDisplayNameChart.series.some((item) => item.key === "metar"),
|
||||||
|
"same-station airport-primary observations should suppress the redundant METAR line even when cadences differ",
|
||||||
|
);
|
||||||
|
|
||||||
const ankaraScanSeedChart = buildFullDayChartData(
|
const ankaraScanSeedChart = buildFullDayChartData(
|
||||||
{
|
{
|
||||||
city: "ankara",
|
city: "ankara",
|
||||||
|
|||||||
@@ -665,6 +665,20 @@ function getCityLocalUtcTimestamp(
|
|||||||
const raw = String(value).trim();
|
const raw = String(value).trim();
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const floatingIsoDateTime = raw.match(
|
||||||
|
/^(\d{4})-(\d{2})-(\d{2})T(\d{1,2}):(\d{2})(?::(\d{2}))?$/,
|
||||||
|
);
|
||||||
|
if (floatingIsoDateTime) {
|
||||||
|
return Date.UTC(
|
||||||
|
Number(floatingIsoDateTime[1]),
|
||||||
|
Number(floatingIsoDateTime[2]) - 1,
|
||||||
|
Number(floatingIsoDateTime[3]),
|
||||||
|
Number(floatingIsoDateTime[4]),
|
||||||
|
Number(floatingIsoDateTime[5]),
|
||||||
|
floatingIsoDateTime[6] ? Number(floatingIsoDateTime[6]) : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (raw.includes("T") || raw.includes("Z") || raw.includes("-")) {
|
if (raw.includes("T") || raw.includes("Z") || raw.includes("-")) {
|
||||||
const d = new Date(raw);
|
const d = new Date(raw);
|
||||||
if (!Number.isNaN(d.getTime())) {
|
if (!Number.isNaN(d.getTime())) {
|
||||||
@@ -725,12 +739,19 @@ function dateFromLocalTime(value?: string | null) {
|
|||||||
return match ? `${match[1]}-${match[2]}-${match[3]}` : null;
|
return match ? `${match[1]}-${match[2]}-${match[3]}` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function laterLocalDate(left?: string | null, right?: string | null) {
|
||||||
|
const a = String(left || "").trim();
|
||||||
|
const b = String(right || "").trim();
|
||||||
|
const isDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||||
|
if (isDate(a) && isDate(b)) return a >= b ? a : b;
|
||||||
|
return isDate(a) ? a : isDate(b) ? b : null;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveChartLocalDate(row: ScanOpportunityRow | null, hourly: HourlyForecast) {
|
function resolveChartLocalDate(row: ScanOpportunityRow | null, hourly: HourlyForecast) {
|
||||||
|
const hourlyDate = hourly?.localDate || dateFromLocalTime(hourly?.localTime);
|
||||||
|
const rowDate = row?.local_date || dateFromLocalTime(row?.local_time);
|
||||||
return (
|
return (
|
||||||
hourly?.localDate ||
|
laterLocalDate(hourlyDate, rowDate) ||
|
||||||
dateFromLocalTime(hourly?.localTime) ||
|
|
||||||
row?.local_date ||
|
|
||||||
dateFromLocalTime(row?.local_time) ||
|
|
||||||
new Date().toISOString().slice(0, 10)
|
new Date().toISOString().slice(0, 10)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -855,12 +876,15 @@ function airportCodeForSeriesLabel(
|
|||||||
const candidates = [
|
const candidates = [
|
||||||
hourly?.airportPrimary?.station_code,
|
hourly?.airportPrimary?.station_code,
|
||||||
(hourly?.airportPrimary as any)?.icao,
|
(hourly?.airportPrimary as any)?.icao,
|
||||||
row?.airport,
|
hourly?.settlementStationCode,
|
||||||
row?.metar_context?.station,
|
row?.metar_context?.station,
|
||||||
|
row?.icao,
|
||||||
|
row?.station_code,
|
||||||
|
row?.airport,
|
||||||
];
|
];
|
||||||
const code = candidates
|
const code = candidates
|
||||||
.map((value) => String(value || "").trim().toUpperCase())
|
.map((value) => String(value || "").trim().toUpperCase())
|
||||||
.find(Boolean);
|
.find((value) => /^[A-Z0-9]{4}$/.test(value));
|
||||||
return code || "";
|
return code || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,6 +925,38 @@ function airportPrimarySeriesLabel(
|
|||||||
return canonicalLabel || payloadLabel || "NOAA MADIS";
|
return canonicalLabel || payloadLabel || "NOAA MADIS";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function airportPrimaryUsesMetarFallback(
|
||||||
|
hourly: HourlyForecast,
|
||||||
|
isHKO: boolean,
|
||||||
|
row?: ScanOpportunityRow | null,
|
||||||
|
) {
|
||||||
|
if (isHKO) return false;
|
||||||
|
const cityKey = normalizeCityKey(row?.city);
|
||||||
|
if (cityKey === "ankara" || cityKey === "istanbul") return false;
|
||||||
|
const canonicalLabel = canonicalAirportPrimarySourceLabel(hourly);
|
||||||
|
if (canonicalLabel && canonicalLabel !== "NOAA MADIS") return false;
|
||||||
|
const payloadLabel = String(hourly?.airportPrimary?.source_label || "").trim();
|
||||||
|
return !payloadLabel || isGenericAirportPrimaryLabel(payloadLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metarStationCodeForSeries(row?: ScanOpportunityRow | null, hourly?: HourlyForecast) {
|
||||||
|
const candidates = [
|
||||||
|
row?.metar_context?.station,
|
||||||
|
hourly?.settlementStationCode,
|
||||||
|
row?.station_code,
|
||||||
|
row?.icao,
|
||||||
|
];
|
||||||
|
return candidates
|
||||||
|
.map((value) => String(value || "").trim().toUpperCase())
|
||||||
|
.find((value) => /^[A-Z0-9]{4}$/.test(value)) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function airportPrimaryMatchesMetarStation(hourly: HourlyForecast, row?: ScanOpportunityRow | null) {
|
||||||
|
const primaryCode = airportCodeForSeriesLabel(hourly, row);
|
||||||
|
const metarCode = metarStationCodeForSeries(row, hourly);
|
||||||
|
return Boolean(primaryCode && metarCode && primaryCode === metarCode);
|
||||||
|
}
|
||||||
|
|
||||||
function airportPrimaryObservationPoints(hourly: HourlyForecast) {
|
function airportPrimaryObservationPoints(hourly: HourlyForecast) {
|
||||||
return appendLatestAirportObservation(
|
return appendLatestAirportObservation(
|
||||||
hourly?.airportPrimaryTodayObs,
|
hourly?.airportPrimaryTodayObs,
|
||||||
@@ -1490,6 +1546,7 @@ type HourlyForecast = {
|
|||||||
multiModelDaily?: Record<string, DailyModelForecast>;
|
multiModelDaily?: Record<string, DailyModelForecast>;
|
||||||
probabilities?: LegacyGaussianProbabilitySource | null;
|
probabilities?: LegacyGaussianProbabilitySource | null;
|
||||||
settlementTodayObs?: ObsPoint[];
|
settlementTodayObs?: ObsPoint[];
|
||||||
|
settlementStationCode?: string | null;
|
||||||
settlementStationLabel?: string | null;
|
settlementStationLabel?: string | null;
|
||||||
metarTodayObs?: ObsPoint[];
|
metarTodayObs?: ObsPoint[];
|
||||||
airportPrimaryTodayObs?: RawObsPoint[];
|
airportPrimaryTodayObs?: RawObsPoint[];
|
||||||
@@ -1549,6 +1606,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
|
|||||||
distribution_all: row.distribution_full || row.distribution_preview || [],
|
distribution_all: row.distribution_full || row.distribution_preview || [],
|
||||||
},
|
},
|
||||||
settlementTodayObs: row.settlement_today_obs || row.metar_context?.settlement_today_obs || undefined,
|
settlementTodayObs: row.settlement_today_obs || row.metar_context?.settlement_today_obs || undefined,
|
||||||
|
settlementStationCode: row.metar_context?.station || row.station_code || row.icao || null,
|
||||||
metarTodayObs: row.metar_today_obs || row.metar_context?.today_obs || row.metar_recent_obs || row.metar_context?.recent_obs || undefined,
|
metarTodayObs: row.metar_today_obs || row.metar_context?.today_obs || row.metar_recent_obs || row.metar_context?.recent_obs || undefined,
|
||||||
airportPrimaryTodayObs: current
|
airportPrimaryTodayObs: current
|
||||||
? appendRawObservationPoint(undefined, current.time, current.temp)
|
? appendRawObservationPoint(undefined, current.time, current.temp)
|
||||||
@@ -1596,6 +1654,8 @@ function mergeHourlyWithLiveObservations(
|
|||||||
airportCurrent: mergeAirportCondition(base.airportCurrent, live.airportCurrent, row, localDate),
|
airportCurrent: mergeAirportCondition(base.airportCurrent, live.airportCurrent, row, localDate),
|
||||||
airportPrimary: mergeAirportCondition(base.airportPrimary, live.airportPrimary, row, localDate),
|
airportPrimary: mergeAirportCondition(base.airportPrimary, live.airportPrimary, row, localDate),
|
||||||
settlementTodayObs: mergeRawObservationPoints(base.settlementTodayObs, live.settlementTodayObs) as ObsPoint[] | undefined,
|
settlementTodayObs: mergeRawObservationPoints(base.settlementTodayObs, live.settlementTodayObs) as ObsPoint[] | undefined,
|
||||||
|
settlementStationCode: detailSource.settlementStationCode || forecastFallback.settlementStationCode || row?.metar_context?.station || null,
|
||||||
|
settlementStationLabel: detailSource.settlementStationLabel || forecastFallback.settlementStationLabel || null,
|
||||||
metarTodayObs: mergeRawObservationPoints(base.metarTodayObs, live.metarTodayObs) as ObsPoint[] | undefined,
|
metarTodayObs: mergeRawObservationPoints(base.metarTodayObs, live.metarTodayObs) as ObsPoint[] | undefined,
|
||||||
airportPrimaryTodayObs: mergeRawObservationPoints(
|
airportPrimaryTodayObs: mergeRawObservationPoints(
|
||||||
base.airportPrimaryTodayObs,
|
base.airportPrimaryTodayObs,
|
||||||
@@ -1849,6 +1909,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
|
|||||||
multiModelDaily: json.multi_model_daily || {},
|
multiModelDaily: json.multi_model_daily || {},
|
||||||
probabilities: json.probabilities || null,
|
probabilities: json.probabilities || null,
|
||||||
settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined,
|
settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined,
|
||||||
|
settlementStationCode: (json as any)?.settlement_station?.settlement_station_code || (json as any)?.settlement_station?.airport_code || null,
|
||||||
settlementStationLabel: (json as any)?.settlement_station?.settlement_station_label || null,
|
settlementStationLabel: (json as any)?.settlement_station?.settlement_station_label || null,
|
||||||
metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_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,
|
airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined,
|
||||||
@@ -2845,7 +2906,14 @@ function buildFullDayChartData(
|
|||||||
Boolean(hourly?.amos?.runway_obs)
|
Boolean(hourly?.amos?.runway_obs)
|
||||||
);
|
);
|
||||||
const isRunwaySensorAggregateSource = isAmscSource || isKoreanAmosSource;
|
const isRunwaySensorAggregateSource = isAmscSource || isKoreanAmosSource;
|
||||||
const shouldRenderMetar = metarObs.length > 0 && !observationSetContains(finalMadisObs, metarObs);
|
const isRedundantMetarFallback =
|
||||||
|
finalMadisObs.length > 0 &&
|
||||||
|
airportPrimaryUsesMetarFallback(hourly, isHKO, row) &&
|
||||||
|
airportPrimaryMatchesMetarStation(hourly, row);
|
||||||
|
const shouldRenderMetar =
|
||||||
|
metarObs.length > 0 &&
|
||||||
|
!isRedundantMetarFallback &&
|
||||||
|
!observationSetContains(finalMadisObs, metarObs);
|
||||||
|
|
||||||
const timelineSet = new Set<number>();
|
const timelineSet = new Set<number>();
|
||||||
runwayHistorySeries.forEach((rhs) => rhs.points.forEach((point) => timelineSet.add(point.ts)));
|
runwayHistorySeries.forEach((rhs) => rhs.points.forEach((point) => timelineSet.add(point.ts)));
|
||||||
@@ -2950,7 +3018,7 @@ function buildFullDayChartData(
|
|||||||
series.push({
|
series.push({
|
||||||
key: "madis",
|
key: "madis",
|
||||||
label: airportPrimarySeriesLabel(hourly, isHKO, row),
|
label: airportPrimarySeriesLabel(hourly, isHKO, row),
|
||||||
source: isHKO ? "HKO" : (hourly?.airportPrimary?.station_code || row?.airport || "MADIS"),
|
source: isHKO ? "HKO" : (airportCodeForSeriesLabel(hourly, row) || row?.airport || "MADIS"),
|
||||||
color: "#0284c7",
|
color: "#0284c7",
|
||||||
dashed: isHKO ? true : false,
|
dashed: isHKO ? true : false,
|
||||||
values: madisVals,
|
values: madisVals,
|
||||||
|
|||||||
Reference in New Issue
Block a user