feat: implement core logic and visualization components for runway temperature monitoring with SSE patching support
This commit is contained in:
@@ -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)"),
|
||||
|
||||
+34
-4
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user