Plot live chart observations immediately
This commit is contained in:
@@ -57,7 +57,7 @@ const PEAK_GLOW_BADGE_CLASS = {
|
|||||||
cooling: "border-slate-200 bg-slate-100 text-slate-500",
|
cooling: "border-slate-200 bg-slate-100 text-slate-500",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000;
|
const PROBABILITY_REFRESH_AFTER_PATCH_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
|
||||||
const FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS = 90_000;
|
const FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS = 90_000;
|
||||||
const NO_PATCH_FULL_DETAIL_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
|
const NO_PATCH_FULL_DETAIL_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
|
||||||
const DETAIL_LOAD_BATCH_DELAY_MS = 0;
|
const DETAIL_LOAD_BATCH_DELAY_MS = 0;
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ export async function runTests() {
|
|||||||
chartSource.includes("setHourly(data)"),
|
chartSource.includes("setHourly(data)"),
|
||||||
"visible chart fallback must refresh the full city detail payload at the current chart resolution when SSE patches stop",
|
"visible chart fallback must refresh the full city detail payload at the current chart resolution when SSE patches stop",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS = DASHBOARD_REFRESH_POLICY_MS.metar") &&
|
||||||
|
!chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000"),
|
||||||
|
"live observation patches should update the plotted line immediately and throttle heavy probability/detail refreshes to METAR cadence",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
chartLogicSource.includes("HOURLY_FORCE_REFRESH_DEDUP_MS") &&
|
chartLogicSource.includes("HOURLY_FORCE_REFRESH_DEDUP_MS") &&
|
||||||
chartLogicSource.includes("maxAgeMs: HOURLY_FORCE_REFRESH_DEDUP_MS") &&
|
chartLogicSource.includes("maxAgeMs: HOURLY_FORCE_REFRESH_DEDUP_MS") &&
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { getTemperatureChartData } from "@/lib/chart-utils";
|
import { getTemperatureChartData } from "@/lib/chart-utils";
|
||||||
import type { CityDetail } from "@/lib/dashboard-types";
|
import type { CityDetail } from "@/lib/dashboard-types";
|
||||||
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
|
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
|
||||||
import { buildFullDayChartData } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
import {
|
||||||
|
buildFullDayChartData,
|
||||||
|
mergePatchIntoHourly,
|
||||||
|
seedHourlyForecastFromRow,
|
||||||
|
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string) {
|
function assert(condition: unknown, message: string) {
|
||||||
if (!condition) throw new Error(message);
|
if (!condition) throw new Error(message);
|
||||||
@@ -247,4 +251,46 @@ export function runTests() {
|
|||||||
normalBaseline.debPast.some((t) => t != null) && normalBaseline.debFuture.some((t) => t != null),
|
normalBaseline.debPast.some((t) => t != null) && normalBaseline.debFuture.some((t) => t != null),
|
||||||
"Normal city: both past and future portions should have data",
|
"Normal city: both past and future portions should have data",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const guangzhouRow = {
|
||||||
|
city: "guangzhou",
|
||||||
|
local_date: "2026-06-10",
|
||||||
|
local_time: "12:45",
|
||||||
|
current_temp: 28.4,
|
||||||
|
current_max_so_far: 29,
|
||||||
|
temp_symbol: "°C",
|
||||||
|
tz_offset_seconds: 8 * 3600,
|
||||||
|
metar_context: {
|
||||||
|
source: "amsc_awos",
|
||||||
|
station_label: "AMSC AWOS",
|
||||||
|
airport_current_temp: 28.4,
|
||||||
|
airport_obs_time: "12:45",
|
||||||
|
airport_max_so_far: 29,
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
const seededGuangzhou = seedHourlyForecastFromRow(guangzhouRow);
|
||||||
|
const guangzhouLiveChart = buildFullDayChartData(guangzhouRow, seededGuangzhou, false);
|
||||||
|
const guangzhouSettlementRunway = guangzhouLiveChart.series.find((item) => item.key === "runway_02L_20R");
|
||||||
|
assert(
|
||||||
|
guangzhouSettlementRunway?.values.some((value) => value === 28.4),
|
||||||
|
"row-seeded runway cities must plot the latest scan-row observation immediately instead of waiting for city detail",
|
||||||
|
);
|
||||||
|
|
||||||
|
const guangzhouPatched = mergePatchIntoHourly(seededGuangzhou, {
|
||||||
|
city: "guangzhou",
|
||||||
|
revision: 42,
|
||||||
|
changes: {
|
||||||
|
temp: 28.8,
|
||||||
|
observed_at_local: "12:48",
|
||||||
|
obs_time: "12:48",
|
||||||
|
source: "amsc_awos",
|
||||||
|
runway_points: [{ runway: "02L/20R", temp: 28.8 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const guangzhouPatchedChart = buildFullDayChartData(guangzhouRow, guangzhouPatched, false);
|
||||||
|
const patchedRunway = guangzhouPatchedChart.series.find((item) => item.key === "runway_02L_20R");
|
||||||
|
assert(
|
||||||
|
patchedRunway?.values.some((value) => value === 28.8),
|
||||||
|
"SSE runway patches must append directly to the chart series without waiting for a force-refreshed detail payload",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -597,7 +597,7 @@ function filterRunwayHistoryToLocalDay(
|
|||||||
...item,
|
...item,
|
||||||
points: filterTimelinePointsToLocalDay(item.points, bounds),
|
points: filterTimelinePointsToLocalDay(item.points, bounds),
|
||||||
}))
|
}))
|
||||||
.filter((item) => item.points.length > 1);
|
.filter((item) => item.points.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(ts: number): string {
|
function formatTimestamp(ts: number): string {
|
||||||
@@ -938,6 +938,103 @@ function runwayPatchPointsFromRunwayObs(runwayObs: any) {
|
|||||||
.filter((point: any): point is { runway: string; temp: number; target_runway_max: number } => point !== null);
|
.filter((point: any): point is { runway: string; temp: number; target_runway_max: number } => point !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rowCurrentObservation(row: ScanOpportunityRow | null) {
|
||||||
|
if (!row) return null;
|
||||||
|
const metarContext = row.metar_context || null;
|
||||||
|
const temp =
|
||||||
|
validNumber(row.current_temp) ??
|
||||||
|
validNumber(metarContext?.airport_current_temp) ??
|
||||||
|
validNumber(metarContext?.last_temp);
|
||||||
|
const time =
|
||||||
|
metarContext?.airport_obs_time ||
|
||||||
|
metarContext?.last_observation_time ||
|
||||||
|
metarContext?.last_time ||
|
||||||
|
row.local_time ||
|
||||||
|
null;
|
||||||
|
if (temp === null || !time) return null;
|
||||||
|
const maxSoFar =
|
||||||
|
validNumber(row.current_max_so_far) ??
|
||||||
|
validNumber(metarContext?.airport_max_so_far) ??
|
||||||
|
validNumber(metarContext?.max_temp) ??
|
||||||
|
temp;
|
||||||
|
return {
|
||||||
|
temp,
|
||||||
|
time: String(time),
|
||||||
|
maxSoFar,
|
||||||
|
sourceCode: String(metarContext?.source || "").trim() || null,
|
||||||
|
sourceLabel: String(metarContext?.station_label || metarContext?.source || "").trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRawObservationPoint(
|
||||||
|
points: RawObsPoint[] | null | undefined,
|
||||||
|
time: string,
|
||||||
|
temp: number,
|
||||||
|
) {
|
||||||
|
const merged = [...(points || [])];
|
||||||
|
const normalizedTime = String(time || "").trim();
|
||||||
|
if (!normalizedTime) return merged;
|
||||||
|
const exists = merged
|
||||||
|
.map(normalizeRawObsPoint)
|
||||||
|
.some((point) => point?.time === normalizedTime && validNumber(point.temp) === temp);
|
||||||
|
if (!exists) merged.push([normalizedTime, temp]);
|
||||||
|
return merged.slice(-MAX_OBS_POINTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowLooksLikeRunwaySensor(row: ScanOpportunityRow | null) {
|
||||||
|
const cityKey = normalizeCityKey(row?.city);
|
||||||
|
const sourceText = [
|
||||||
|
row?.metar_context?.source,
|
||||||
|
row?.metar_context?.station,
|
||||||
|
row?.metar_context?.station_label,
|
||||||
|
row?.airport,
|
||||||
|
]
|
||||||
|
.map((value) => String(value || "").toLowerCase())
|
||||||
|
.join(" ");
|
||||||
|
return (
|
||||||
|
AMSC_RUNWAY_CITIES.has(cityKey) ||
|
||||||
|
sourceText.includes("amsc") ||
|
||||||
|
sourceText.includes("awos") ||
|
||||||
|
sourceText.includes("runway")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedRunwayPlateHistoryFromRow(
|
||||||
|
row: ScanOpportunityRow | null,
|
||||||
|
existing: Record<string, Array<Record<string, unknown>>> | undefined,
|
||||||
|
) {
|
||||||
|
const current = rowCurrentObservation(row);
|
||||||
|
const cityKey = normalizeCityKey(row?.city);
|
||||||
|
const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || [];
|
||||||
|
if (!current || !settlementPairs.length || !rowLooksLikeRunwaySensor(row)) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const history: Record<string, Array<Record<string, unknown>>> = {};
|
||||||
|
Object.entries(existing || {}).forEach(([rwy, points]) => {
|
||||||
|
if (Array.isArray(points)) history[rwy] = [...points];
|
||||||
|
});
|
||||||
|
|
||||||
|
settlementPairs.forEach((pair) => {
|
||||||
|
const rwy = `${normalizeRunwayLabel(pair[0])}/${normalizeRunwayLabel(pair[1])}`;
|
||||||
|
const rwyHistory = history[rwy] || [];
|
||||||
|
const exists = rwyHistory.some((point) => {
|
||||||
|
return point.timestamp === current.time || point.time === current.time || point.observed_at === current.time;
|
||||||
|
});
|
||||||
|
if (exists) return;
|
||||||
|
history[rwy] = [
|
||||||
|
...rwyHistory,
|
||||||
|
{
|
||||||
|
timestamp: current.time,
|
||||||
|
temp_c: current.temp,
|
||||||
|
value: current.temp,
|
||||||
|
},
|
||||||
|
].slice(-MAX_OBS_POINTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.keys(history).length ? history : existing;
|
||||||
|
}
|
||||||
|
|
||||||
type HourlyForecast = {
|
type HourlyForecast = {
|
||||||
forecastTodayHigh?: number | null;
|
forecastTodayHigh?: number | null;
|
||||||
debPrediction?: number | null;
|
debPrediction?: number | null;
|
||||||
@@ -966,6 +1063,32 @@ type HourlyForecast = {
|
|||||||
|
|
||||||
function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForecast {
|
function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForecast {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
const current = rowCurrentObservation(row);
|
||||||
|
const sourceCode = current?.sourceCode || undefined;
|
||||||
|
const sourceLabel = current?.sourceLabel || undefined;
|
||||||
|
const airportCurrent = current
|
||||||
|
? {
|
||||||
|
temp: current.temp,
|
||||||
|
obs_time: current.time,
|
||||||
|
max_so_far: current.maxSoFar,
|
||||||
|
source_code: sourceCode,
|
||||||
|
source_label: sourceLabel,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const airportPrimary = current
|
||||||
|
? {
|
||||||
|
temp: current.temp,
|
||||||
|
obs_time: current.time,
|
||||||
|
max_so_far: current.maxSoFar,
|
||||||
|
source_code: sourceCode,
|
||||||
|
source_label: sourceLabel,
|
||||||
|
source: sourceCode,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const seededRunwayPlateHistory = seedRunwayPlateHistoryFromRow(
|
||||||
|
row,
|
||||||
|
(row as any)?.runway_plate_history || undefined,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
forecastTodayHigh: null,
|
forecastTodayHigh: null,
|
||||||
debPrediction: validNumber(row.deb_prediction),
|
debPrediction: validNumber(row.deb_prediction),
|
||||||
@@ -976,12 +1099,12 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
|
|||||||
times: [],
|
times: [],
|
||||||
temps: [],
|
temps: [],
|
||||||
modelCurves: undefined,
|
modelCurves: undefined,
|
||||||
runwayPlateHistory: (row as any)?.runway_plate_history || undefined,
|
runwayPlateHistory: seededRunwayPlateHistory,
|
||||||
runwayBandHistory: undefined,
|
runwayBandHistory: undefined,
|
||||||
amos: null,
|
amos: null,
|
||||||
current: null,
|
current: null,
|
||||||
airportCurrent: null,
|
airportCurrent,
|
||||||
airportPrimary: null,
|
airportPrimary,
|
||||||
wundergroundCurrent: (row as any)?.wunderground_current || null,
|
wundergroundCurrent: (row as any)?.wunderground_current || null,
|
||||||
forecastDaily: [],
|
forecastDaily: [],
|
||||||
multiModelDaily: {},
|
multiModelDaily: {},
|
||||||
@@ -992,7 +1115,9 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
|
|||||||
},
|
},
|
||||||
settlementTodayObs: row.settlement_today_obs || row.metar_context?.settlement_today_obs || undefined,
|
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,
|
metarTodayObs: row.metar_today_obs || row.metar_context?.today_obs || row.metar_recent_obs || row.metar_context?.recent_obs || undefined,
|
||||||
airportPrimaryTodayObs: undefined,
|
airportPrimaryTodayObs: current
|
||||||
|
? appendRawObservationPoint(undefined, current.time, current.temp)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1942,7 +2067,7 @@ function buildFullDayChartData(
|
|||||||
localDayBounds,
|
localDayBounds,
|
||||||
);
|
);
|
||||||
const runwayHistorySeries = filterRunwayHistoryToLocalDay(
|
const runwayHistorySeries = filterRunwayHistoryToLocalDay(
|
||||||
buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr, 2, isEn),
|
buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr, 1, isEn),
|
||||||
localDayBounds,
|
localDayBounds,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user