Plot live chart observations immediately

This commit is contained in:
2569718930@qq.com
2026-06-10 12:56:43 +08:00
parent 98026e510a
commit 93fe7a9b69
4 changed files with 184 additions and 8 deletions
@@ -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,
); );