feat: implement core logic and visualization components for runway temperature monitoring with SSE patching support

This commit is contained in:
2569718930@qq.com
2026-05-27 00:44:38 +08:00
parent 6b2c99cea4
commit 40dcd6e8f0
4 changed files with 137 additions and 22 deletions
@@ -35,6 +35,15 @@ import {
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
export { clearCityDetailCache } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) {
const cityOffsetMs = (tzOffsetSeconds ?? 0) * 1000;
const cityNow = new Date(Date.now() + cityOffsetMs + new Date().getTimezoneOffset() * 60_000);
const y = cityNow.getFullYear();
const m = String(cityNow.getMonth() + 1).padStart(2, "0");
const d = String(cityNow.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
// ── Main component ─────────────────────────────────────────────────────
export function LiveTemperatureThresholdChart({
@@ -74,12 +83,16 @@ export function LiveTemperatureThresholdChart({
const hasLoadedHourlyDetailRef = useRef(false);
const lastPatchAtRef = useRef<number>(Date.now());
const lastAppliedPatchRevisionRef = useRef<number>(0);
const localDayRolloverFetchDateRef = useRef<string>("");
const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(true);
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
const [refAreaRight, setRefAreaRight] = useState<number | null>(null);
const [zoomRange, setZoomRange] = useState<[number, number] | null>(null);
const [targetResolution, setTargetResolution] = useState<string>("10m");
const [currentCityLocalDate, setCurrentCityLocalDate] = useState(() =>
formatCityLocalDate(row?.tz_offset_seconds),
);
useEffect(() => {
setUserToggledKeys({});
@@ -91,8 +104,18 @@ export function LiveTemperatureThresholdChart({
hasLoadedHourlyDetailRef.current = false;
lastPatchAtRef.current = Date.now();
lastAppliedPatchRevisionRef.current = 0;
localDayRolloverFetchDateRef.current = "";
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
}, [city]);
useEffect(() => {
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
const id = setInterval(() => {
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
}, 60_000);
return () => clearInterval(id);
}, [row?.tz_offset_seconds]);
useEffect(() => {
if (!city) {
setIsHourlyLoading(false);
@@ -216,7 +239,40 @@ export function LiveTemperatureThresholdChart({
};
}, [city, compact, isActive, isMaximized]);
const { data, series } = useMemo(() => buildFullDayChartData(row, hourly, isEn), [row, hourly, isEn]);
useEffect(() => {
if (!city || !currentCityLocalDate) return;
const loadedLocalDate = hourly?.localDate || row?.local_date || "";
if (currentCityLocalDate === loadedLocalDate) return;
if (localDayRolloverFetchDateRef.current === currentCityLocalDate) return;
localDayRolloverFetchDateRef.current = currentCityLocalDate;
let cancelled = false;
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
.then((data) => {
if (cancelled || !data) return;
hasLoadedHourlyDetailRef.current = true;
setHourly(data);
})
.catch(() => {
if (!cancelled) localDayRolloverFetchDateRef.current = "";
});
return () => {
cancelled = true;
};
}, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution]);
const chartHourly = useMemo<HourlyForecast>(() => {
if (!hourly) return hourly;
const loadedLocalDate = hourly.localDate || row?.local_date || "";
if (currentCityLocalDate && currentCityLocalDate !== loadedLocalDate) {
return { ...hourly, localDate: currentCityLocalDate };
}
return hourly;
}, [hourly, currentCityLocalDate, row?.local_date]);
const chartLocalDate = chartHourly?.localDate || row?.local_date || currentCityLocalDate;
const { data, series } = useMemo(() => buildFullDayChartData(row, chartHourly, isEn), [row, chartHourly, isEn]);
const autoWindowRange = useMemo(
() => (viewMode === "auto" ? getDebPeakWindowRange(data, series) : null),
@@ -253,17 +309,17 @@ export function LiveTemperatureThresholdChart({
const tzOffset = row?.tz_offset_seconds ?? 0;
const settlementObs = useMemo(() => {
let obs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, undefined, row?.local_date || null);
if (!obs.length && !hourly?.runwayPlateHistory) {
const mObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs, tzOffset, undefined, row?.local_date || null);
let obs = normObs(chartHourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, undefined, chartLocalDate || null);
if (!obs.length && !chartHourly?.runwayPlateHistory) {
const mObs = normObs(chartHourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs, tzOffset, undefined, chartLocalDate || null);
if (mObs.length > 0) {
obs = mObs;
}
}
return obs;
}, [row, hourly, tzOffset]);
}, [row, chartHourly, tzOffset, chartLocalDate]);
const runwayPlates = useMemo(() => buildRunwayPlates(hourly?.amos, row, settlementObs), [hourly?.amos, row, settlementObs]);
const runwayPlates = useMemo(() => buildRunwayPlates(chartHourly?.amos, row, settlementObs), [chartHourly?.amos, row, settlementObs]);
const hasRunwayData = runwayPlates.length > 0;
const settlementPlate = useMemo(() => runwayPlates.find((p) => p.isSettlement), [runwayPlates]);
@@ -293,26 +349,26 @@ export function LiveTemperatureThresholdChart({
metarHighLabel,
runwayHeaderLabel,
runwayHighLabel,
} = useMemo(() => getLiveObservationLabels(row, hourly), [row, hourly]);
} = useMemo(() => getLiveObservationLabels(row, chartHourly), [row, chartHourly]);
const { currentRunwayTemp, observedHighMetar, observedHighRunway } = useMemo(
() => getObservationDisplayMetrics(row, hourly, settlementPlate),
[row, hourly, settlementPlate],
() => getObservationDisplayMetrics(row, chartHourly, settlementPlate),
[row, chartHourly, settlementPlate],
);
const displayRunwayTemp = liveTemp ?? currentRunwayTemp;
const wundergroundDailyHigh = validNumber(hourly?.airportCurrent?.max_so_far ?? hourly?.airportPrimary?.max_so_far) ?? null;
const wundergroundDailyHigh = validNumber(chartHourly?.airportCurrent?.max_so_far ?? chartHourly?.airportPrimary?.max_so_far) ?? null;
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
const localDateStr = chartLocalDate || new Date().toISOString().slice(0, 10);
const modelSources = (row?.model_cluster_sources && Object.keys(row.model_cluster_sources).length > 0)
? row.model_cluster_sources
: (hourly?.multiModelDaily?.[localDateStr]?.models || null);
: (chartHourly?.multiModelDaily?.[localDateStr]?.models || null);
const modelValues = Object.values(modelSources || {})
.map(validNumber)
.filter((v): v is number => v !== 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 debVal = validNumber(hourly?.debPrediction) ?? validNumber(row?.deb_prediction) ?? null;
const debVal = validNumber(chartHourly?.debPrediction) ?? validNumber(row?.deb_prediction) ?? null;
const spread = (modelMax !== null && modelMin !== null) ? modelMax - modelMin : null;
const spreadLabel = spread === null ? "" : (spread <= 2.0 ? "低分歧" : (spread <= 4.0 ? "中等分歧" : "高分歧"));
@@ -120,6 +120,12 @@ export function runTests() {
assert(chart.includes("isHourlyLoading"), "temperature chart must keep a per-panel hourly loading state");
assert(chartCanvas.includes("加载图表") && chartCanvas.includes("absolute inset-2"), "temperature chart must render an in-chart loading overlay");
assert(chart.includes("hasLoadedHourlyDetailRef"), "temperature chart must distinguish first load from background refreshes");
assert(chart.includes("currentCityLocalDate"), "temperature chart must track the current city-local date while the page stays open");
assert(chart.includes("localDayRolloverFetchDateRef"), "temperature chart must avoid duplicate midnight rollover detail fetches");
assert(
chart.includes("ignoreCache: true") && chart.includes("currentCityLocalDate !== loadedLocalDate"),
"temperature chart must background-refresh full city detail when the city-local day rolls over",
);
const fallbackRefreshBlock = chart.match(/const refreshFullDetail = \(\) => \{[\s\S]*?\n \};/)?.[0] || "";
assert(
!fallbackRefreshBlock.includes("setIsHourlyLoading(true)"),
@@ -394,12 +394,12 @@ export function runTests() {
null,
);
assert(
panamaLabels.runwayHeaderLabel === "机场气象站",
"Panama City/MPMG should not be labeled as runway observations when no runway sensor feed exists",
panamaLabels.runwayHeaderLabel === "机场报文",
"Panama City/MPMG should be labeled as an airport METAR report when no station or runway sensor feed exists",
);
assert(
panamaLabels.runwayHighLabel === "机场气象站",
"Panama City high label should use airport weather station wording, not runway wording",
panamaLabels.runwayHighLabel === "机场报文",
"Panama City high label should use airport METAR report wording, not weather-station or runway wording",
);
const newYorkWithMadis = __buildTemperatureChartDataForTest(
@@ -610,6 +610,36 @@ export function runTests() {
"Full-day chart should start at local 00:00 when the DEB hourly path has a midnight point",
);
const chongqingRolledToNextDay = __buildTemperatureChartDataForTest(
{
city: "chongqing",
local_date: "2026-05-26",
local_time: "23:50",
tz_offset_seconds: 8 * 60 * 60,
deb_prediction: 22,
} as any,
{
localDate: "2026-05-27",
localTime: "00:34",
times: ["00:00", "06:00", "12:00", "18:00", "23:00"],
temps: [25.2, 25.6, 28.4, 27.6, 26.1],
debPrediction: 30.1,
} as any,
"1D",
);
const chongqingNextDayStart = Date.UTC(2026, 4, 27, 0, 0, 0);
const chongqingNextDayEnd = Date.UTC(2026, 4, 28, 0, 0, 0);
assert(
chongqingRolledToNextDay.data.every((point) => point.ts >= chongqingNextDayStart && point.ts < chongqingNextDayEnd),
"Full-day chart should switch to the city-detail localDate after local midnight instead of keeping stale terminal row.local_date",
);
const chongqingNextDayDeb = seriesByKey(chongqingRolledToNextDay.series, "hourly_forecast") as any;
const chongqingNextDayDebValues = chongqingNextDayDeb.values.filter((value: number | null): value is number => value !== null);
assert(
Math.max(...chongqingNextDayDebValues) === 30.1,
"DEB curve should use the next local day's detail debPrediction after local midnight",
);
// ── Runway range band and runway_max test ──
const shanghaiWithBand = __buildTemperatureChartDataForTest(
{
@@ -327,6 +327,21 @@ function getLocalDayBounds(localDateStr: string): LocalDayBounds | null {
return Number.isFinite(start) ? { start, end: start + DAY_MS } : null;
}
function dateFromLocalTime(value?: string | null) {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value || "").trim());
return match ? `${match[1]}-${match[2]}-${match[3]}` : null;
}
function resolveChartLocalDate(row: ScanOpportunityRow | null, hourly: HourlyForecast) {
return (
hourly?.localDate ||
dateFromLocalTime(hourly?.localTime) ||
row?.local_date ||
dateFromLocalTime(row?.local_time) ||
new Date().toISOString().slice(0, 10)
);
}
function isWithinLocalDay(ts: number | null, bounds: LocalDayBounds | null) {
return ts !== null && Number.isFinite(ts) && (!bounds || (ts >= bounds.start && ts < bounds.end));
}
@@ -423,7 +438,7 @@ function getObservationDisplayMetrics(
settlementPlate?: { maxTemp: number | null } | null,
) {
const tzOffset = row?.tz_offset_seconds ?? 0;
const localDateStr = row?.local_date || null;
const localDateStr = resolveChartLocalDate(row, hourly);
const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, MAX_OBS_POINTS, localDateStr);
const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset, MAX_OBS_POINTS, localDateStr);
const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset, MAX_OBS_POINTS, localDateStr);
@@ -509,6 +524,7 @@ function runwayLabelFromPair(rawPair: unknown, index: number) {
type HourlyForecast = {
forecastTodayHigh?: number | null;
debPrediction?: number | null;
localDate?: string | null;
localTime?: string | null;
times: string[];
temps: Array<number | null>;
@@ -531,6 +547,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
return {
forecastTodayHigh: null,
debPrediction: validNumber(row.deb_prediction),
localDate: row.local_date || null,
localTime: row.local_time || null,
times: [],
temps: [],
@@ -559,6 +576,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
return {
forecastTodayHigh: json.forecast?.today_high ?? null,
debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null,
localDate: json.local_date || (json as any)?.overview?.local_date || null,
localTime: json.local_time || null,
times: hourlySource.times || [],
temps: hourlySource.temps || [],
@@ -687,7 +705,7 @@ function getLiveObservationLabels(
: isTaipei ? "CWA (10分钟)"
: isWeatherStation ? "气象站实测"
: isRunwaySensorCity ? "跑道实测 (1分钟)"
: "机场气象站";
: "机场报文";
const metarHeaderLabel = (isShenzhen || isHKO) ? "天文台实测 (10分钟)"
: "METAR 结算 (30分钟)";
@@ -700,7 +718,7 @@ function getLiveObservationLabels(
: isTaipei ? "CWA"
: isWeatherStation ? "气象站"
: isRunwaySensorCity ? "跑道实测"
: "机场气象站";
: "机场报文";
const metarHighLabel = isShenzhen ? "天文台"
: isHKO ? "天文台"
@@ -735,6 +753,7 @@ function mergePatchIntoHourly(
...(prev || {
forecastTodayHigh: null,
debPrediction: null,
localDate: null,
localTime: null,
times: [],
temps: [],
@@ -744,6 +763,10 @@ function mergePatchIntoHourly(
...explicitHourlyPatch,
};
if (typeof (changes as any).local_date === "string") {
next.localDate = (changes as any).local_date;
}
if (changes.amos && typeof changes.amos === "object") {
const oldAmos = prev?.amos || {};
const newAmos = changes.amos as AmosData;
@@ -982,7 +1005,7 @@ function buildDailyChartData(
hourly: HourlyForecast,
daysCount: number,
): { data: Array<Record<string, string | number | null>>; series: EvidenceSeries[] } {
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
const localDateStr = resolveChartLocalDate(row, hourly);
const slots = generateDailySlots(localDateStr, daysCount);
const series: EvidenceSeries[] = [
@@ -1162,7 +1185,7 @@ function buildFullDayChartData(
isEn: boolean,
): { data: Array<Record<string, any>>; series: EvidenceSeries[] } {
const tzOffset = row?.tz_offset_seconds ?? 0;
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
const localDateStr = resolveChartLocalDate(row, hourly);
const localDayBounds = getLocalDayBounds(localDateStr);
const settlementObs = filterTimelinePointsToLocalDay(