feat: implement LiveTemperatureThresholdChart and associated data processing logic with comprehensive unit tests
This commit is contained in:
@@ -151,6 +151,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
|
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
|
||||||
const [liveTemp, setLiveTemp] = useState<number | null>(null);
|
const [liveTemp, setLiveTemp] = useState<number | null>(null);
|
||||||
const [isHourlyLoading, setIsHourlyLoading] = useState(false);
|
const [isHourlyLoading, setIsHourlyLoading] = useState(false);
|
||||||
|
const hasLoadedHourlyDetailRef = useRef(false);
|
||||||
const lastPatchAtRef = useRef<number>(Date.now());
|
const lastPatchAtRef = useRef<number>(Date.now());
|
||||||
const lastAppliedPatchRevisionRef = useRef<number>(0);
|
const lastAppliedPatchRevisionRef = useRef<number>(0);
|
||||||
|
|
||||||
@@ -165,6 +166,9 @@ export function LiveTemperatureThresholdChart({
|
|||||||
setZoomRange(null);
|
setZoomRange(null);
|
||||||
setViewMode("auto");
|
setViewMode("auto");
|
||||||
setShowRunwayDetails(true);
|
setShowRunwayDetails(true);
|
||||||
|
setHourly(seedHourlyForecastFromRow(row));
|
||||||
|
setIsHourlyLoading(Boolean(city));
|
||||||
|
hasLoadedHourlyDetailRef.current = false;
|
||||||
lastPatchAtRef.current = Date.now();
|
lastPatchAtRef.current = Date.now();
|
||||||
lastAppliedPatchRevisionRef.current = 0;
|
lastAppliedPatchRevisionRef.current = 0;
|
||||||
}, [city]);
|
}, [city]);
|
||||||
@@ -188,13 +192,16 @@ export function LiveTemperatureThresholdChart({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
|
||||||
|
hasLoadedHourlyDetailRef.current = true;
|
||||||
setHourly(cached.data);
|
setHourly(cached.data);
|
||||||
setIsHourlyLoading(false);
|
setIsHourlyLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setHourly(seedHourlyForecastFromRow(row));
|
if (!hasLoadedHourlyDetailRef.current) {
|
||||||
setIsHourlyLoading(true);
|
setHourly(seedHourlyForecastFromRow(row));
|
||||||
|
}
|
||||||
|
setIsHourlyLoading(!hasLoadedHourlyDetailRef.current);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
// Prioritize active slots, stagger/delay background slots to optimize load performance
|
// Prioritize active slots, stagger/delay background slots to optimize load performance
|
||||||
@@ -204,6 +211,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
fetchHourlyForecastForCity(city, { resolution: targetResolution })
|
fetchHourlyForecastForCity(city, { resolution: targetResolution })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled || !data) return;
|
if (cancelled || !data) return;
|
||||||
|
hasLoadedHourlyDetailRef.current = true;
|
||||||
setHourly(data);
|
setHourly(data);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -230,10 +238,10 @@ export function LiveTemperatureThresholdChart({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resyncVersion || !city) return;
|
if (!resyncVersion || !city) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setIsHourlyLoading(true);
|
|
||||||
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled || !data) return;
|
if (cancelled || !data) return;
|
||||||
|
hasLoadedHourlyDetailRef.current = true;
|
||||||
setHourly(data);
|
setHourly(data);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -252,11 +260,11 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
const refreshFullDetail = () => {
|
const refreshFullDetail = () => {
|
||||||
lastPatchAtRef.current = Date.now();
|
lastPatchAtRef.current = Date.now();
|
||||||
setIsHourlyLoading(true);
|
|
||||||
|
|
||||||
fetchHourlyForecastForCity(city, { ignoreCache: true })
|
fetchHourlyForecastForCity(city, { ignoreCache: true })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled || !data) return;
|
if (cancelled || !data) return;
|
||||||
|
hasLoadedHourlyDetailRef.current = true;
|
||||||
setHourly(data);
|
setHourly(data);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -325,9 +333,9 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
const tzOffset = row?.tz_offset_seconds ?? 0;
|
const tzOffset = row?.tz_offset_seconds ?? 0;
|
||||||
const settlementObs = useMemo(() => {
|
const settlementObs = useMemo(() => {
|
||||||
let obs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset);
|
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) {
|
if (!obs.length && !hourly?.runwayPlateHistory) {
|
||||||
const mObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs, tzOffset);
|
const mObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs, tzOffset, undefined, row?.local_date || null);
|
||||||
if (mObs.length > 0) {
|
if (mObs.length > 0) {
|
||||||
obs = mObs;
|
obs = mObs;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,17 @@ export function runTests() {
|
|||||||
assert(chart.includes("nearestSeriesValue"), "temperature chart tooltip must fall back to nearest non-null value for connected sparse lines");
|
assert(chart.includes("nearestSeriesValue"), "temperature chart tooltip must fall back to nearest non-null value for connected sparse lines");
|
||||||
assert(chart.includes("isHourlyLoading"), "temperature chart must keep a per-panel hourly loading state");
|
assert(chart.includes("isHourlyLoading"), "temperature chart must keep a per-panel hourly loading state");
|
||||||
assert(chart.includes("加载图表") && chart.includes("absolute inset-2"), "temperature chart must render an in-chart loading overlay");
|
assert(chart.includes("加载图表") && chart.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");
|
||||||
|
const fallbackRefreshBlock = chart.match(/const refreshFullDetail = \(\) => \{[\s\S]*?\n \};/)?.[0] || "";
|
||||||
|
assert(
|
||||||
|
!fallbackRefreshBlock.includes("setIsHourlyLoading(true)"),
|
||||||
|
"no-patch fallback refresh should update the chart in the background without showing the loading overlay",
|
||||||
|
);
|
||||||
|
const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, targetResolution\]\);/)?.[0] || "";
|
||||||
|
assert(
|
||||||
|
!resyncBlock.includes("setIsHourlyLoading(true)"),
|
||||||
|
"SSE replay resync should refresh full detail in the background without showing the loading overlay",
|
||||||
|
);
|
||||||
assert(chart.includes("viewMode"), "temperature chart must expose a view mode for DEB-peak auto view versus full-day view");
|
assert(chart.includes("viewMode"), "temperature chart must expose a view mode for DEB-peak auto view versus full-day view");
|
||||||
assert(chart.includes("getDebPeakWindowRange"), "temperature chart must derive its default view from the DEB peak window");
|
assert(chart.includes("getDebPeakWindowRange"), "temperature chart must derive its default view from the DEB peak window");
|
||||||
assert(chart.includes("nextTargetResolution"), "temperature chart must derive target resolution without setting state on every render");
|
assert(chart.includes("nextTargetResolution"), "temperature chart must derive target resolution without setting state on every render");
|
||||||
|
|||||||
+55
@@ -331,6 +331,27 @@ export function runTests() {
|
|||||||
"Istanbul/MGM high label should be weather station",
|
"Istanbul/MGM high label should be weather station",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const panamaLabels = __getLiveObservationLabelsForTest(
|
||||||
|
{
|
||||||
|
city: "panama city",
|
||||||
|
airport: "MPMG",
|
||||||
|
metar_context: {
|
||||||
|
source: "metar",
|
||||||
|
station: "MPMG",
|
||||||
|
station_label: "MPMG METAR",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
panamaLabels.runwayHeaderLabel === "机场气象站",
|
||||||
|
"Panama City/MPMG should not be labeled as runway observations when no runway sensor feed exists",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
panamaLabels.runwayHighLabel === "机场气象站",
|
||||||
|
"Panama City high label should use airport weather station wording, not runway wording",
|
||||||
|
);
|
||||||
|
|
||||||
const newYorkWithMadis = __buildTemperatureChartDataForTest(
|
const newYorkWithMadis = __buildTemperatureChartDataForTest(
|
||||||
{
|
{
|
||||||
city: "new york",
|
city: "new york",
|
||||||
@@ -505,6 +526,40 @@ export function runTests() {
|
|||||||
"DEB curve should not be pulled into an impossible negative range by stale row deb_prediction=0",
|
"DEB curve should not be pulled into an impossible negative range by stale row deb_prediction=0",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const qingdaoFullDay = __buildTemperatureChartDataForTest(
|
||||||
|
{
|
||||||
|
city: "qingdao",
|
||||||
|
local_date: "2026-05-26",
|
||||||
|
local_time: "23:30",
|
||||||
|
tz_offset_seconds: 8 * 60 * 60,
|
||||||
|
deb_prediction: 22,
|
||||||
|
runway_plate_history: {
|
||||||
|
"16/34": [
|
||||||
|
{ time: "2026-05-25T23:30:00+08:00", temp: 23.8 },
|
||||||
|
{ time: "2026-05-26T00:05:00+08:00", temp: 23.5 },
|
||||||
|
{ time: "2026-05-26T12:00:00+08:00", temp: 21.6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
localTime: "23:30",
|
||||||
|
times: ["00:00", "06:00", "12:00", "18:00", "23:00"],
|
||||||
|
temps: [24, 19, 21.5, 21.5, 20],
|
||||||
|
debPrediction: 22,
|
||||||
|
} as any,
|
||||||
|
"1D",
|
||||||
|
);
|
||||||
|
const qingdaoDayStart = Date.UTC(2026, 4, 26, 0, 0, 0);
|
||||||
|
const qingdaoDayEnd = Date.UTC(2026, 4, 27, 0, 0, 0);
|
||||||
|
assert(
|
||||||
|
qingdaoFullDay.data.every((point) => point.ts >= qingdaoDayStart && point.ts < qingdaoDayEnd),
|
||||||
|
"Full-day chart should clamp observation history to the selected local_date so DEB does not appear broken after cross-day runway history",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
qingdaoFullDay.data[0]?.ts === qingdaoDayStart,
|
||||||
|
"Full-day chart should start at local 00:00 when the DEB hourly path has a midnight point",
|
||||||
|
);
|
||||||
|
|
||||||
// ── Runway range band and runway_max test ──
|
// ── Runway range band and runway_max test ──
|
||||||
const shanghaiWithBand = __buildTemperatureChartDataForTest(
|
const shanghaiWithBand = __buildTemperatureChartDataForTest(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { CityPatch } from "@/hooks/use-sse-patches";
|
|||||||
const ROLLING_WINDOW_BEFORE_MS = 12 * 60 * 60 * 1000;
|
const ROLLING_WINDOW_BEFORE_MS = 12 * 60 * 60 * 1000;
|
||||||
const ROLLING_WINDOW_AFTER_LIVE_MS = 2 * 60 * 60 * 1000;
|
const ROLLING_WINDOW_AFTER_LIVE_MS = 2 * 60 * 60 * 1000;
|
||||||
const ROLLING_WINDOW_AFTER_FORECAST_MS = 8 * 60 * 60 * 1000;
|
const ROLLING_WINDOW_AFTER_FORECAST_MS = 8 * 60 * 60 * 1000;
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const SETTLEMENT_RUNWAY_PAIRS: Record<string, Array<[string, string]>> = {
|
const SETTLEMENT_RUNWAY_PAIRS: Record<string, Array<[string, string]>> = {
|
||||||
shanghai: [["17L", "35R"]],
|
shanghai: [["17L", "35R"]],
|
||||||
@@ -196,6 +197,7 @@ type RunwayHistorySeries = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type TemperatureBandPoint = { ts: number; high: number; low: number; avg: number };
|
type TemperatureBandPoint = { ts: number; high: number; low: number; avg: number };
|
||||||
|
type LocalDayBounds = { start: number; end: number };
|
||||||
|
|
||||||
const MAX_OBS_POINTS = 1440;
|
const MAX_OBS_POINTS = 1440;
|
||||||
const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
|
const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
|
||||||
@@ -311,6 +313,45 @@ function getCityLocalUtcTimestamp(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLocalDayBounds(localDateStr: string): LocalDayBounds | null {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(localDateStr);
|
||||||
|
if (!match) return null;
|
||||||
|
const start = Date.UTC(
|
||||||
|
Number(match[1]),
|
||||||
|
Number(match[2]) - 1,
|
||||||
|
Number(match[3]),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return Number.isFinite(start) ? { start, end: start + DAY_MS } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinLocalDay(ts: number | null, bounds: LocalDayBounds | null) {
|
||||||
|
return ts !== null && Number.isFinite(ts) && (!bounds || (ts >= bounds.start && ts < bounds.end));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterTimelinePointsToLocalDay<T extends { ts: number }>(
|
||||||
|
points: T[],
|
||||||
|
bounds: LocalDayBounds | null,
|
||||||
|
) {
|
||||||
|
if (!bounds) return points;
|
||||||
|
return points.filter((point) => isWithinLocalDay(point.ts, bounds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterRunwayHistoryToLocalDay(
|
||||||
|
series: RunwayHistorySeries[],
|
||||||
|
bounds: LocalDayBounds | null,
|
||||||
|
) {
|
||||||
|
if (!bounds) return series;
|
||||||
|
return series
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
points: filterTimelinePointsToLocalDay(item.points, bounds),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.points.length > 1);
|
||||||
|
}
|
||||||
|
|
||||||
function formatTimestamp(ts: number): string {
|
function formatTimestamp(ts: number): string {
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}`;
|
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}`;
|
||||||
@@ -323,16 +364,21 @@ function normalizeRawObsPoint(point: RawObsPoint): ObsPoint | null {
|
|||||||
return point;
|
return point;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normObs(points: RawObsPoint[] | null | undefined, tzOffsetSeconds: number, limit = MAX_OBS_POINTS) {
|
function normObs(
|
||||||
|
points: RawObsPoint[] | null | undefined,
|
||||||
|
tzOffsetSeconds: number,
|
||||||
|
limit = MAX_OBS_POINTS,
|
||||||
|
referenceLocalDate?: string | null,
|
||||||
|
) {
|
||||||
return (points || [])
|
return (points || [])
|
||||||
.map(normalizeRawObsPoint)
|
.map(normalizeRawObsPoint)
|
||||||
.filter((p): p is ObsPoint => p !== null)
|
.filter((p): p is ObsPoint => p !== null)
|
||||||
.filter((p) => validNumber(p.temp) !== null && p.time)
|
.filter((p) => validNumber(p.temp) !== null && p.time)
|
||||||
.map((p) => ({
|
.map((p) => {
|
||||||
ts: getCityLocalUtcTimestamp(p.time, tzOffsetSeconds)!,
|
const ts = getCityLocalUtcTimestamp(p.time, tzOffsetSeconds, referenceLocalDate);
|
||||||
value: Number(p.temp),
|
return ts === null ? null : { ts, value: Number(p.temp) };
|
||||||
}))
|
})
|
||||||
.filter((p) => p.ts !== null)
|
.filter((p): p is { ts: number; value: number } => p !== null)
|
||||||
.slice(-limit);
|
.slice(-limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,9 +423,10 @@ function getObservationDisplayMetrics(
|
|||||||
settlementPlate?: { maxTemp: number | null } | null,
|
settlementPlate?: { maxTemp: number | null } | null,
|
||||||
) {
|
) {
|
||||||
const tzOffset = row?.tz_offset_seconds ?? 0;
|
const tzOffset = row?.tz_offset_seconds ?? 0;
|
||||||
const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset);
|
const localDateStr = row?.local_date || null;
|
||||||
const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset);
|
const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, MAX_OBS_POINTS, localDateStr);
|
||||||
const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset);
|
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);
|
||||||
const latestSettlement = latestObservationValue(settlementObs);
|
const latestSettlement = latestObservationValue(settlementObs);
|
||||||
const latestMetar = latestObservationValue(metarObs);
|
const latestMetar = latestObservationValue(metarObs);
|
||||||
const latestMadis = latestObservationValue(madisObs);
|
const latestMadis = latestObservationValue(madisObs);
|
||||||
@@ -627,6 +674,7 @@ function getLiveObservationLabels(
|
|||||||
const hasRealStationNetwork =
|
const hasRealStationNetwork =
|
||||||
weatherStationCities.has(normalizedKey) ||
|
weatherStationCities.has(normalizedKey) ||
|
||||||
/\b(mgm|turkey_mgm|jma_amedas|fmi|knmi|cowin_obs|ims|ncm|aeroweb|madis_hfmetar|singapore_mss)\b/.test(sourceTokens);
|
/\b(mgm|turkey_mgm|jma_amedas|fmi|knmi|cowin_obs|ims|ncm|aeroweb|madis_hfmetar|singapore_mss)\b/.test(sourceTokens);
|
||||||
|
const isRunwaySensorCity = runwaySensorCities.has(normalizedKey);
|
||||||
const isWeatherStation = !runwaySensorCities.has(normalizedKey)
|
const isWeatherStation = !runwaySensorCities.has(normalizedKey)
|
||||||
&& !isHKO && !isShenzhen && !isTokyo && !isSingapore && !isParis && !isTaipei
|
&& !isHKO && !isShenzhen && !isTokyo && !isSingapore && !isParis && !isTaipei
|
||||||
&& hasRealStationNetwork;
|
&& hasRealStationNetwork;
|
||||||
@@ -638,7 +686,8 @@ function getLiveObservationLabels(
|
|||||||
: isParis ? "官方机场观测 (15分钟)"
|
: isParis ? "官方机场观测 (15分钟)"
|
||||||
: isTaipei ? "CWA (10分钟)"
|
: isTaipei ? "CWA (10分钟)"
|
||||||
: isWeatherStation ? "气象站实测"
|
: isWeatherStation ? "气象站实测"
|
||||||
: "跑道实测 (1分钟)";
|
: isRunwaySensorCity ? "跑道实测 (1分钟)"
|
||||||
|
: "机场气象站";
|
||||||
|
|
||||||
const metarHeaderLabel = (isShenzhen || isHKO) ? "天文台实测 (10分钟)"
|
const metarHeaderLabel = (isShenzhen || isHKO) ? "天文台实测 (10分钟)"
|
||||||
: "METAR 结算 (30分钟)";
|
: "METAR 结算 (30分钟)";
|
||||||
@@ -650,7 +699,8 @@ function getLiveObservationLabels(
|
|||||||
: isParis ? "官方机场观测"
|
: isParis ? "官方机场观测"
|
||||||
: isTaipei ? "CWA"
|
: isTaipei ? "CWA"
|
||||||
: isWeatherStation ? "气象站"
|
: isWeatherStation ? "气象站"
|
||||||
: "跑道实测";
|
: isRunwaySensorCity ? "跑道实测"
|
||||||
|
: "机场气象站";
|
||||||
|
|
||||||
const metarHighLabel = isShenzhen ? "天文台"
|
const metarHighLabel = isShenzhen ? "天文台"
|
||||||
: isHKO ? "天文台"
|
: isHKO ? "天文台"
|
||||||
@@ -1075,10 +1125,12 @@ function valuesForHourlyTimes(
|
|||||||
values: Array<number | null | undefined>,
|
values: Array<number | null | undefined>,
|
||||||
tzOffsetSeconds: number,
|
tzOffsetSeconds: number,
|
||||||
localDateStr: string,
|
localDateStr: string,
|
||||||
|
bounds: LocalDayBounds | null = null,
|
||||||
) {
|
) {
|
||||||
const result: Array<number | null> = new Array(size).fill(null);
|
const result: Array<number | null> = new Array(size).fill(null);
|
||||||
(times || []).forEach((time, index) => {
|
(times || []).forEach((time, index) => {
|
||||||
const ts = getCityLocalUtcTimestamp(time, tzOffsetSeconds, localDateStr);
|
const ts = getCityLocalUtcTimestamp(time, tzOffsetSeconds, localDateStr);
|
||||||
|
if (!isWithinLocalDay(ts, bounds)) return;
|
||||||
if (ts === null) return;
|
if (ts === null) return;
|
||||||
const value = validNumber(values[index]);
|
const value = validNumber(values[index]);
|
||||||
if (value === null) return;
|
if (value === null) return;
|
||||||
@@ -1094,12 +1146,13 @@ function addHourlyTimesToTimeline(
|
|||||||
values: Array<number | null | undefined> | undefined,
|
values: Array<number | null | undefined> | undefined,
|
||||||
tzOffsetSeconds: number,
|
tzOffsetSeconds: number,
|
||||||
localDateStr: string,
|
localDateStr: string,
|
||||||
|
bounds: LocalDayBounds | null = null,
|
||||||
) {
|
) {
|
||||||
if (!times?.length || !values?.length) return;
|
if (!times?.length || !values?.length) return;
|
||||||
times.forEach((time, index) => {
|
times.forEach((time, index) => {
|
||||||
if (validNumber(values[index]) === null) return;
|
if (validNumber(values[index]) === null) return;
|
||||||
const ts = getCityLocalUtcTimestamp(time, tzOffsetSeconds, localDateStr);
|
const ts = getCityLocalUtcTimestamp(time, tzOffsetSeconds, localDateStr);
|
||||||
if (ts !== null) timeline.add(ts);
|
if (ts !== null && isWithinLocalDay(ts, bounds)) timeline.add(ts);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1110,11 +1163,21 @@ function buildFullDayChartData(
|
|||||||
): { data: Array<Record<string, any>>; series: EvidenceSeries[] } {
|
): { data: Array<Record<string, any>>; series: EvidenceSeries[] } {
|
||||||
const tzOffset = row?.tz_offset_seconds ?? 0;
|
const tzOffset = row?.tz_offset_seconds ?? 0;
|
||||||
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
|
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
|
||||||
|
const localDayBounds = getLocalDayBounds(localDateStr);
|
||||||
|
|
||||||
const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset);
|
const settlementObs = filterTimelinePointsToLocalDay(
|
||||||
const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset);
|
normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, MAX_OBS_POINTS, localDateStr),
|
||||||
const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset);
|
localDayBounds,
|
||||||
const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr);
|
);
|
||||||
|
const metarObs = filterTimelinePointsToLocalDay(
|
||||||
|
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),
|
||||||
|
localDayBounds,
|
||||||
|
);
|
||||||
|
const madisObs = filterTimelinePointsToLocalDay(normObs(hourly?.airportPrimaryTodayObs, tzOffset, MAX_OBS_POINTS, localDateStr), localDayBounds);
|
||||||
|
const runwayHistorySeries = filterRunwayHistoryToLocalDay(
|
||||||
|
buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr),
|
||||||
|
localDayBounds,
|
||||||
|
);
|
||||||
|
|
||||||
const settlementCityKey = normalizeCityKey(row?.city);
|
const settlementCityKey = normalizeCityKey(row?.city);
|
||||||
const isShenzhen = settlementCityKey === 'shenzhen';
|
const isShenzhen = settlementCityKey === 'shenzhen';
|
||||||
@@ -1143,7 +1206,7 @@ function buildFullDayChartData(
|
|||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}).filter((v): v is NonNullable<typeof v> => v !== null);
|
}).filter((v): v is NonNullable<typeof v> => v !== null && isWithinLocalDay(v.ts, localDayBounds));
|
||||||
|
|
||||||
const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan'
|
const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan'
|
||||||
|| settlementCityKey === 'shenzhen' || (row?.city || '').toLowerCase().includes('hong kong')
|
|| settlementCityKey === 'shenzhen' || (row?.city || '').toLowerCase().includes('hong kong')
|
||||||
@@ -1169,11 +1232,11 @@ function buildFullDayChartData(
|
|||||||
hourly.localTime || row?.local_time,
|
hourly.localTime || row?.local_time,
|
||||||
hourly.forecastTodayHigh,
|
hourly.forecastTodayHigh,
|
||||||
);
|
);
|
||||||
addHourlyTimesToTimeline(timelineSet, hourly.times, debPath.debTemps, tzOffset, localDateStr);
|
addHourlyTimesToTimeline(timelineSet, hourly.times, debPath.debTemps, tzOffset, localDateStr, localDayBounds);
|
||||||
}
|
}
|
||||||
if (hourly?.times?.length && hourly?.modelCurves) {
|
if (hourly?.times?.length && hourly?.modelCurves) {
|
||||||
Object.values(hourly.modelCurves).forEach((modelTemps) => {
|
Object.values(hourly.modelCurves).forEach((modelTemps) => {
|
||||||
addHourlyTimesToTimeline(timelineSet, hourly.times, modelTemps, tzOffset, localDateStr);
|
addHourlyTimesToTimeline(timelineSet, hourly.times, modelTemps, tzOffset, localDateStr, localDayBounds);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1263,7 +1326,7 @@ function buildFullDayChartData(
|
|||||||
|
|
||||||
// ── DEB forecast curve ──
|
// ── DEB forecast curve ──
|
||||||
if (hourly?.times?.length && debPath?.debTemps.length) {
|
if (hourly?.times?.length && debPath?.debTemps.length) {
|
||||||
const debVals = valuesForHourlyTimes(n, indexByTs, hourly.times, debPath.debTemps, tzOffset, localDateStr);
|
const debVals = valuesForHourlyTimes(n, indexByTs, hourly.times, debPath.debTemps, tzOffset, localDateStr, localDayBounds);
|
||||||
if (debVals.some((v) => v !== null)) {
|
if (debVals.some((v) => v !== null)) {
|
||||||
series.push({
|
series.push({
|
||||||
key: "hourly_forecast",
|
key: "hourly_forecast",
|
||||||
@@ -1282,7 +1345,7 @@ function buildFullDayChartData(
|
|||||||
Object.keys(hourly.modelCurves).forEach((model, idx) => {
|
Object.keys(hourly.modelCurves).forEach((model, idx) => {
|
||||||
const modelTemps = hourly.modelCurves![model];
|
const modelTemps = hourly.modelCurves![model];
|
||||||
if (!modelTemps?.length) return;
|
if (!modelTemps?.length) return;
|
||||||
const vals = valuesForHourlyTimes(n, indexByTs, hourly.times, modelTemps, tzOffset, localDateStr);
|
const vals = valuesForHourlyTimes(n, indexByTs, hourly.times, modelTemps, tzOffset, localDateStr, localDayBounds);
|
||||||
if (vals.some((v) => v !== null)) {
|
if (vals.some((v) => v !== null)) {
|
||||||
series.push({
|
series.push({
|
||||||
key: `model_curve_${model}`,
|
key: `model_curve_${model}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user