气温走势图 X 轴从固定24h改为半小时间隔的时间滑动窗口

横坐标由 00:00~23:00 硬编码改为半小时间隔槽位,parseTimeSlot 替代 parseHourOfDay。数据映射粒度从小时提升到半小时,窗口随当前时间滑动。
This commit is contained in:
2569718930@qq.com
2026-05-25 05:49:40 +08:00
parent 4c92a1ad4d
commit b5d5e96f0b
@@ -43,10 +43,13 @@ type RunwayObsPayload = {
} | null> | null; } | null> | null;
}; };
const DAILY_CHART_HOURS = Array.from( // Semi-hourly buckets for the 24-hour day — gives the chart enough resolution
{ length: 24 }, // without making the x-axis unreadable when showing a 12 hour window.
(_, index) => `${String(index).padStart(2, "0")}:00`, const HALF_HOUR_SLOTS = Array.from({ length: 48 }, (_, i) => {
); const h = Math.floor(i / 2);
const m = i % 2 === 0 ? "00" : "30";
return `${String(h).padStart(2, "0")}:${m}`;
});
const VISIBLE_WINDOW_HOURS = 12; const VISIBLE_WINDOW_HOURS = 12;
@@ -64,17 +67,22 @@ function normalizeObs(points?: ObsPoint[] | null, limit = 88) {
})); }));
} }
function parseHourOfDay(value?: string | null) { function parseTimeSlot(value?: string | null) {
const raw = String(value || "").trim(); const raw = String(value || "").trim();
if (!raw) return null; if (!raw) return null;
// Try ISO / full date first
const parsed = new Date(raw); const parsed = new Date(raw);
if (!Number.isNaN(parsed.getTime())) { if (!Number.isNaN(parsed.getTime())) {
return parsed.getHours(); const h = parsed.getHours();
const m = parsed.getMinutes();
return h * 2 + (m >= 30 ? 1 : 0);
} }
const match = raw.match(/(?:^|\D)([01]?\d|2[0-3])[:][0-5]\d/); // Parse "HH:MM" or "HHMM" or "HH:MM:SS"
if (match?.[1] !== undefined) { const match = raw.match(/(?:^|\D)([01]?\d|2[0-3])[:]([0-5]\d)/);
const hour = Number(match[1]); if (match?.[1] !== undefined && match?.[2] !== undefined) {
return Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : null; const h = Number(match[1]);
const m = Number(match[2]);
if (h >= 0 && h < 24) return h * 2 + (m >= 30 ? 1 : 0);
} }
return null; return null;
} }
@@ -109,9 +117,9 @@ function buildModelCurves(row: ScanOpportunityRow | null, length: number, hourly
); );
const values = Array.from({ length }, (): number | null => null); const values = Array.from({ length }, (): number | null => null);
hourly.times.forEach((t, i) => { hourly.times.forEach((t, i) => {
const h = parseHourOfDay(t); const slot = parseTimeSlot(t);
if (h !== null && h >= 0 && h < length && i < hourly.temps.length) { if (slot !== null && slot >= 0 && slot < length && i < hourly.temps.length) {
values[h] = validNumber(debPath.debTemps[i]); values[slot] = validNumber(debPath.debTemps[i]);
} }
}); });
if (values.some((v) => v !== null)) { if (values.some((v) => v !== null)) {
@@ -190,15 +198,15 @@ function extractRunwayPointSeries(row: ScanOpportunityRow | null, length: number
function buildEvidenceChart(row: ScanOpportunityRow | null, hourly: HourlyForecast) { function buildEvidenceChart(row: ScanOpportunityRow | null, hourly: HourlyForecast) {
const settlement = normalizeObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs); const settlement = normalizeObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs);
const metar = normalizeObs(row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs); const metar = normalizeObs(row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs);
const labels = DAILY_CHART_HOURS; const labels = HALF_HOUR_SLOTS;
const length = labels.length; const length = labels.length;
const align = (points: Array<{ label: string; value: number }>) => { const align = (points: Array<{ label: string; value: number }>) => {
if (!points.length) return Array.from({ length }, (): number | null => null); if (!points.length) return Array.from({ length }, (): number | null => null);
const values = Array.from({ length }, (): number | null => null); const values = Array.from({ length }, (): number | null => null);
points.forEach((point, index) => { points.forEach((point, index) => {
const hour = parseHourOfDay(point.label); const slot = parseTimeSlot(point.label);
const bucket = hour ?? Math.min(index, length - 1); const bucket = slot ?? Math.min(index, length - 1);
values[bucket] = point.value; values[bucket] = point.value;
}); });
return values; return values;
@@ -255,8 +263,11 @@ function buildEvidenceChart(row: ScanOpportunityRow | null, hourly: HourlyForeca
return { data, series }; return { data, series };
} }
function currentHourForWindow(row: ScanOpportunityRow | null, hourly: HourlyForecast) { function currentSlotForWindow(row: ScanOpportunityRow | null, hourly: HourlyForecast) {
return parseHourOfDay(hourly?.localTime || row?.local_time) ?? new Date().getHours(); const slot = parseTimeSlot(hourly?.localTime || row?.local_time);
if (slot !== null) return slot;
const now = new Date();
return now.getHours() * 2 + (now.getMinutes() >= 30 ? 1 : 0);
} }
function buildMovingWindowData( function buildMovingWindowData(
@@ -265,10 +276,12 @@ function buildMovingWindowData(
hourly: HourlyForecast, hourly: HourlyForecast,
) { ) {
if (!data.length) return data; if (!data.length) return data;
const currentHour = currentHourForWindow(row, hourly); const totalHalfHours = 48;
const endHour = Math.min(23, Math.max(VISIBLE_WINDOW_HOURS - 1, currentHour + 4)); const windowSlots = VISIBLE_WINDOW_HOURS * 2; // 24 half-hour slots = 12 hours
const startHour = Math.max(0, endHour - VISIBLE_WINDOW_HOURS + 1); const currentSlot = currentSlotForWindow(row, hourly);
return data.slice(startHour, endHour + 1); const endSlot = Math.min(totalHalfHours - 1, Math.max(windowSlots - 1, currentSlot + 8));
const startSlot = Math.max(0, endSlot - windowSlots + 1);
return data.slice(startSlot, endSlot + 1);
} }
function parseTemperatureOptionsFromText(value?: string | null) { function parseTemperatureOptionsFromText(value?: string | null) {