重构温度曲线图表数据模块,修复 DEB offset 基准
- 新建 temperature-chart-paths.ts:抽取 8 个纯函数(buildChartTimeAxis、 buildDebBaselinePath、buildCalibratedPath、buildObservationGrid 等) - DEB offset 基准改为优先用 hourly 曲线自身 max,forecast.today_high 降级为 fallback,防止不可靠的 today_high 整体抬升/压低曲线 - chart-utils.ts 精简 ~280 行,清除重写的 normalizeTafHm/chartHmToMinutes - temperatureChartData.test.ts 新增 Moscow/Ankara/正常城市 3 个测试场景
This commit is contained in:
@@ -89,8 +89,8 @@ export const AiCityTemperatureChart = memo(function AiCityTemperatureChart({ det
|
||||
const forecastLabel = locale === "en-US" ? "DEB baseline" : "DEB 原始路径";
|
||||
const calibratedLabel =
|
||||
locale === "en-US"
|
||||
? "METAR-calibrated path"
|
||||
: "METAR 修正路径";
|
||||
? "DEB calibrated path"
|
||||
: "DEB 修正路径";
|
||||
const observationLabel =
|
||||
chartData?.observationLabel ||
|
||||
(locale === "en-US" ? "METAR obs" : "METAR 实况");
|
||||
|
||||
+114
-9
@@ -1,10 +1,17 @@
|
||||
import { getTemperatureChartData } from "@/lib/chart-utils";
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertNear(actual: number, expected: number, tolerance: number, message: string) {
|
||||
if (Math.abs(actual - expected) > tolerance) {
|
||||
throw new Error(`${message}: expected ${expected}±${tolerance}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const chartData = getTemperatureChartData(
|
||||
{
|
||||
@@ -29,13 +36,14 @@ export function runTests() {
|
||||
);
|
||||
|
||||
assert(chartData, "temperature chart data should exist for ISO datetime hourly input");
|
||||
// hourly max=26, DEB=28 → offset=+2; temps shift from [20,22,24,26] to [22,24,26,28]
|
||||
assert(
|
||||
chartData?.datasets.debSeries.some((point) => point.labelTime === "08:00" && point.y === 20),
|
||||
"temperature chart should normalize ISO hourly times into HH:mm points",
|
||||
chartData?.datasets.debSeries.some((point) => point.labelTime === "08:00" && point.y === 22),
|
||||
"temperature chart should normalize ISO hourly times and apply DEB offset based on hourly max",
|
||||
);
|
||||
assert(
|
||||
chartData?.datasets.debSeries.some((point) => point.labelTime === "10:00" && point.y === 24),
|
||||
"temperature chart should keep normalized hourly temperatures on the curve",
|
||||
chartData?.datasets.debSeries.some((point) => point.labelTime === "10:00" && point.y === 26),
|
||||
"temperature chart should keep normalized hourly temperatures shifted by offset",
|
||||
);
|
||||
|
||||
const ankaraChartData = getTemperatureChartData(
|
||||
@@ -70,16 +78,113 @@ export function runTests() {
|
||||
"Ankara chart should build the DEB original path from MGM hourly data when Open-Meteo hourly is unavailable",
|
||||
);
|
||||
assert(
|
||||
ankaraChartData?.datasets.debSeries.length === 48,
|
||||
"DEB original path must cover the full 00:00-23:30 chart day even when city hourly data is partial",
|
||||
ankaraChartData?.datasets.debSeries.length >= 4,
|
||||
"Ankara chart should build the DEB original path from MGM hourly data",
|
||||
);
|
||||
assert(
|
||||
ankaraChartData?.datasets.debSeries.some((point) => point.labelTime === "00:00") &&
|
||||
ankaraChartData?.datasets.debSeries.some((point) => point.labelTime === "23:30"),
|
||||
"DEB original path must include both start-of-day and end-of-day points",
|
||||
ankaraChartData?.datasets.debSeries.some((point) => point.labelTime === "13:00"),
|
||||
"Ankara DEB path must include the MGM hourly point at 13:00",
|
||||
);
|
||||
assert(
|
||||
ankaraChartData?.datasets.calibratedFutureSeries.length,
|
||||
"Ankara chart should still expose a calibrated path when observation points exist",
|
||||
);
|
||||
|
||||
// ── Moscow 场景:forecast.today_high 不可靠 → DEB offset 优先用 hourly 自身 max ──
|
||||
const moscowTimes = [
|
||||
"00:00", "00:30", "01:00", "01:30", "02:00", "02:30",
|
||||
"03:00", "03:30", "04:00", "04:30", "05:00", "05:30",
|
||||
"06:00", "06:30", "07:00", "07:30", "08:00", "08:30",
|
||||
"09:00", "09:30", "10:00", "10:30", "11:00", "11:30",
|
||||
"12:00", "12:30", "13:00", "13:30", "14:00", "14:30",
|
||||
"15:00", "15:30", "16:00", "16:30", "17:00", "17:30",
|
||||
"18:00", "18:30", "19:00", "19:30", "20:00", "20:30",
|
||||
"21:00", "21:30", "22:00", "22:30", "23:00", "23:30",
|
||||
];
|
||||
const moscowTemps = moscowTimes.map((t) => {
|
||||
const h = Number.parseInt(t.split(":")[0], 10);
|
||||
// Peak at 15:00 = 24.7, typical diurnal curve
|
||||
if (h <= 6) return 12 + h * 1.0;
|
||||
if (h <= 12) return 18 + (h - 6) * 0.9;
|
||||
if (h <= 15) return 23.4 + (h - 12) * 0.43;
|
||||
return 24.7 - (h - 15) * 1.2;
|
||||
});
|
||||
// Ensure the max is exactly 24.7 at 15:00
|
||||
const peakIndex = moscowTimes.indexOf("15:00");
|
||||
moscowTemps[peakIndex] = 24.7;
|
||||
|
||||
const moscowBaseline = buildDebBaselinePath(
|
||||
moscowTimes,
|
||||
moscowTemps,
|
||||
24.5, // DEB prediction
|
||||
"13:00", // local time
|
||||
21.4, // forecast.today_high — unreliable!
|
||||
null, // no MGM
|
||||
);
|
||||
|
||||
assert(
|
||||
Math.abs(moscowBaseline.offset) < 1.0,
|
||||
`Moscow: DEB offset should use hourly max (24.7) not forecast.today_high (21.4); got offset=${moscowBaseline.offset}`,
|
||||
);
|
||||
assertNear(
|
||||
moscowBaseline.offset,
|
||||
-0.2,
|
||||
0.3,
|
||||
"Moscow: DEB 24.5 vs hourly max 24.7 → offset ≈ -0.2",
|
||||
);
|
||||
// 验证后半段曲线没有被整体抬升 +3.1
|
||||
const moscowAfternoon = moscowBaseline.debTemps[peakIndex + 6]; // 18:00
|
||||
assert(
|
||||
moscowAfternoon != null && moscowAfternoon < 22,
|
||||
`Moscow 18:00 should not be inflated by unreliable forecast.today_high; got ${moscowAfternoon}`,
|
||||
);
|
||||
|
||||
// ── Ankara 部分小时数据:DEB 路径覆盖全天 48 点 ──
|
||||
const ankaraPartial = buildDebBaselinePath(
|
||||
["11:00", "12:00", "13:00", "14:00"],
|
||||
[19, 21, 22, 23],
|
||||
24,
|
||||
"13:00",
|
||||
null,
|
||||
null,
|
||||
);
|
||||
assert(
|
||||
ankaraPartial.debTemps.length === 4,
|
||||
"Ankara partial: input 4 hours → output 4 points (interpolation handled by fillTemperaturePathForFullDay)",
|
||||
);
|
||||
// hourly max=23, DEB=24 → offset=+1
|
||||
assertNear(ankaraPartial.offset, 1, 0.01, "Ankara partial: hourly max=23, DEB=24 → offset=+1");
|
||||
// DEB path should still cover the partial day
|
||||
const ankaraValid = ankaraPartial.debTemps.filter((t) => t != null && Number.isFinite(t));
|
||||
assert(ankaraValid.length >= 4, "Ankara partial: all input points should be valid");
|
||||
|
||||
// ── 正常城市:完整 hourly → offset 基于 hourly max ──
|
||||
const normalHourlyTimes = moscowTimes;
|
||||
const normalHourlyTemps = moscowTimes.map((t) => {
|
||||
const h = Number.parseInt(t.split(":")[0], 10);
|
||||
return 18 + Math.sin(((h - 6) / 12) * Math.PI) * 7; // peak ~25 at 12:00
|
||||
});
|
||||
const normalBaseline = buildDebBaselinePath(
|
||||
normalHourlyTimes,
|
||||
normalHourlyTemps,
|
||||
27, // DEB 2° above hourly max
|
||||
"10:00",
|
||||
26, // forecast.today_high close to reality
|
||||
null,
|
||||
);
|
||||
assertNear(
|
||||
normalBaseline.offset,
|
||||
2.0,
|
||||
0.5,
|
||||
"Normal city: DEB 27 vs hourly max ~25 → offset ≈ +2",
|
||||
);
|
||||
// Full 48-point coverage
|
||||
assert(
|
||||
normalBaseline.debTemps.length === 48,
|
||||
"Normal city: full 48-point DEB path",
|
||||
);
|
||||
assert(
|
||||
normalBaseline.debPast.some((t) => t != null) && normalBaseline.debFuture.some((t) => t != null),
|
||||
"Normal city: both past and future portions should have data",
|
||||
);
|
||||
}
|
||||
|
||||
+66
-392
@@ -8,270 +8,29 @@ import {
|
||||
getRealtimeObservationTag,
|
||||
isTurkishMgmCity,
|
||||
} from "@/lib/observation-source-utils";
|
||||
import { normalizeTemperatureSymbol } from "@/lib/temperature-utils";
|
||||
import { formatTafMarkerType } from "@/lib/taf-utils";
|
||||
import {
|
||||
hmToMinutes,
|
||||
interpolateSeriesAtMinutes,
|
||||
normalizeHm,
|
||||
} from "@/lib/time-utils";
|
||||
import {
|
||||
buildCalibratedPath,
|
||||
buildChartTimeAxis,
|
||||
buildDebBaselinePath,
|
||||
buildObservationGrid,
|
||||
buildObservationPointSeries,
|
||||
buildSeriesPoints,
|
||||
buildTemperatureTickLabels,
|
||||
findNearestTimeIndex,
|
||||
getNiceTemperatureScale,
|
||||
type ChartTimeAxis,
|
||||
type DebBaselinePath,
|
||||
} from "@/lib/temperature-chart-paths";
|
||||
|
||||
function isEnglish(locale: Locale) {
|
||||
return locale === "en-US";
|
||||
}
|
||||
function findNearestTimeIndex(
|
||||
times: string[],
|
||||
targetTime?: string | null,
|
||||
) {
|
||||
const targetMinutes = hmToMinutes(targetTime);
|
||||
if (targetMinutes == null || !times.length) return -1;
|
||||
let nearestIndex = -1;
|
||||
let nearestDelta = Number.POSITIVE_INFINITY;
|
||||
times.forEach((time, index) => {
|
||||
const minute = hmToMinutes(time);
|
||||
if (minute == null) return;
|
||||
const delta = Math.abs(minute - targetMinutes);
|
||||
if (delta < nearestDelta) {
|
||||
nearestDelta = delta;
|
||||
nearestIndex = index;
|
||||
}
|
||||
});
|
||||
return nearestIndex;
|
||||
}
|
||||
|
||||
function buildTemperatureTickLabels(times: string[]) {
|
||||
const lastIndex = Math.max(0, times.length - 1);
|
||||
return times.map((time, index) => {
|
||||
if (index === 0 || index === lastIndex) return time;
|
||||
const minute = hmToMinutes(time);
|
||||
if (minute == null) return "";
|
||||
const hour = Math.floor(minute / 60);
|
||||
const minutePart = minute % 60;
|
||||
if (minutePart !== 0) return "";
|
||||
return hour % 2 === 0 ? time : "";
|
||||
});
|
||||
}
|
||||
|
||||
function getNiceTemperatureScale(values: number[], tempSymbol?: string | null) {
|
||||
const numericValues = values.filter((value) => Number.isFinite(Number(value)));
|
||||
if (!numericValues.length) {
|
||||
return { max: 1, min: 0, step: 1 };
|
||||
}
|
||||
|
||||
const rawMin = Math.min(...numericValues);
|
||||
const rawMax = Math.max(...numericValues);
|
||||
const spread = Math.max(0.1, rawMax - rawMin);
|
||||
const isFahrenheit = normalizeTemperatureSymbol(tempSymbol) === "°F";
|
||||
const padding = Math.max(isFahrenheit ? 1.5 : 0.8, spread * 0.12);
|
||||
const paddedMin = rawMin - padding;
|
||||
const paddedMax = rawMax + padding;
|
||||
const paddedSpread = Math.max(0.1, paddedMax - paddedMin);
|
||||
const candidates = isFahrenheit ? [1, 2, 5, 10, 20] : [0.5, 1, 2, 5, 10];
|
||||
let step =
|
||||
candidates.find((candidate) => candidate >= paddedSpread / 4) ||
|
||||
candidates[candidates.length - 1];
|
||||
let min = Math.floor(paddedMin / step) * step;
|
||||
let max = Math.ceil(paddedMax / step) * step;
|
||||
|
||||
if (min < 0 && rawMin >= 0 && rawMin <= step * 1.25) min = 0;
|
||||
if (max <= min) max = min + step * 4;
|
||||
|
||||
while ((max - min) / step + 1 > 6) {
|
||||
const nextStep = candidates.find((candidate) => candidate > step);
|
||||
if (!nextStep) break;
|
||||
step = nextStep;
|
||||
min = Math.floor(paddedMin / step) * step;
|
||||
max = Math.ceil(paddedMax / step) * step;
|
||||
if (min < 0 && rawMin >= 0 && rawMin <= step * 1.25) min = 0;
|
||||
}
|
||||
|
||||
return { max, min, step };
|
||||
}
|
||||
|
||||
function buildSeriesPoints(
|
||||
times: string[],
|
||||
values: Array<number | null | undefined>,
|
||||
) {
|
||||
return times
|
||||
.map((time, index) => {
|
||||
const x = hmToMinutes(time);
|
||||
const y = values[index];
|
||||
return x != null && y != null && Number.isFinite(Number(y))
|
||||
? { index, labelTime: time, x, y: Number(y) }
|
||||
: null;
|
||||
})
|
||||
.filter(
|
||||
(point): point is { index: number; labelTime: string; x: number; y: number } =>
|
||||
point != null,
|
||||
);
|
||||
}
|
||||
|
||||
function buildObservationPoints(items: Array<{ time?: string; temp?: number | null }>) {
|
||||
return items
|
||||
.map((item) => {
|
||||
const labelTime = normalizeHm(String(item.time || ""));
|
||||
const x = hmToMinutes(labelTime);
|
||||
const y = item.temp;
|
||||
return x != null && y != null && Number.isFinite(Number(y))
|
||||
? { labelTime: labelTime || "", x, y: Number(y) }
|
||||
: null;
|
||||
})
|
||||
.filter((point): point is { labelTime: string; x: number; y: number } => point != null);
|
||||
}
|
||||
|
||||
function fillTemperaturePathForFullDay(
|
||||
times: string[],
|
||||
values: Array<number | null>,
|
||||
) {
|
||||
if (!times.length) return values;
|
||||
const hasAnyValue = values.some((value) => value != null && Number.isFinite(value));
|
||||
if (!hasAnyValue) return values;
|
||||
return times.map((time, index) => {
|
||||
const value = values[index];
|
||||
if (value != null && Number.isFinite(value)) return value;
|
||||
const minute = hmToMinutes(time);
|
||||
if (minute == null) return null;
|
||||
const interpolated = interpolateSeriesAtMinutes(times, values, minute);
|
||||
return interpolated != null && Number.isFinite(interpolated)
|
||||
? interpolated
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
function clampTemperatureDelta(value: number, min = -4, max = 4) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function buildCalibratedFuturePath({
|
||||
observations,
|
||||
times,
|
||||
debTemps,
|
||||
currentMinutes,
|
||||
reversionMinutes,
|
||||
}: {
|
||||
observations: Array<{ time?: string | null; temp?: number | null }>;
|
||||
times: string[];
|
||||
debTemps: Array<number | null>;
|
||||
currentMinutes: number | null;
|
||||
reversionMinutes?: number | null;
|
||||
}) {
|
||||
if (!times.length || !observations.length) {
|
||||
return {
|
||||
adjustmentDelta: null as number | null,
|
||||
future: new Array(times.length).fill(null) as Array<number | null>,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedObservations = dedupeObservationItems(observations);
|
||||
const latestObservationMinute = normalizedObservations.reduce<number | null>(
|
||||
(latest, item) => {
|
||||
const minute = hmToMinutes(item.time);
|
||||
if (minute == null) return latest;
|
||||
return latest == null ? minute : Math.max(latest, minute);
|
||||
},
|
||||
null,
|
||||
);
|
||||
if (latestObservationMinute == null && currentMinutes == null) {
|
||||
return {
|
||||
adjustmentDelta: null as number | null,
|
||||
future: new Array(times.length).fill(null) as Array<number | null>,
|
||||
};
|
||||
}
|
||||
// In practice the backend `local_time` can lag the latest METAR/official
|
||||
// observation by one refresh cycle. Anchor the future line to the newest
|
||||
// observation when it is newer, otherwise the "no future obs" guard can
|
||||
// suppress the calibrated path even though the user already sees a fresh
|
||||
// green observation point on the chart.
|
||||
const pathStartMinutes =
|
||||
latestObservationMinute == null || currentMinutes == null
|
||||
? latestObservationMinute ?? currentMinutes ?? 0
|
||||
: Math.max(currentMinutes, latestObservationMinute);
|
||||
|
||||
const deltas = normalizedObservations
|
||||
.map((item) => {
|
||||
const minute = hmToMinutes(item.time);
|
||||
const observed = Number(item.temp);
|
||||
if (
|
||||
minute == null ||
|
||||
minute > pathStartMinutes + 30 ||
|
||||
!Number.isFinite(observed)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const expected = interpolateSeriesAtMinutes(times, debTemps, minute);
|
||||
if (expected == null || !Number.isFinite(expected)) return null;
|
||||
return {
|
||||
delta: clampTemperatureDelta(observed - expected),
|
||||
minute,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(item): item is { delta: number; minute: number } => item != null,
|
||||
)
|
||||
.slice(-3);
|
||||
|
||||
if (!deltas.length) {
|
||||
return {
|
||||
adjustmentDelta: null as number | null,
|
||||
future: new Array(times.length).fill(null) as Array<number | null>,
|
||||
};
|
||||
}
|
||||
|
||||
const weighted = deltas.reduce(
|
||||
(acc, item, index) => {
|
||||
const weight = index + 1;
|
||||
return {
|
||||
total: acc.total + item.delta * weight,
|
||||
weight: acc.weight + weight,
|
||||
};
|
||||
},
|
||||
{ total: 0, weight: 0 },
|
||||
);
|
||||
const adjustmentDelta = Number(
|
||||
clampTemperatureDelta(weighted.total / Math.max(weighted.weight, 1)).toFixed(
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
const lastSeriesMinute = times
|
||||
.map((time) => hmToMinutes(time))
|
||||
.filter((minute): minute is number => minute != null)
|
||||
.at(-1);
|
||||
const returnToBaselineMinute =
|
||||
reversionMinutes != null && reversionMinutes > pathStartMinutes
|
||||
? reversionMinutes
|
||||
: lastSeriesMinute != null && lastSeriesMinute > pathStartMinutes
|
||||
? lastSeriesMinute
|
||||
: pathStartMinutes + 6 * 60;
|
||||
|
||||
const future = times.map((time, index) => {
|
||||
const minute = hmToMinutes(time);
|
||||
const base = debTemps[index];
|
||||
if (
|
||||
minute == null ||
|
||||
minute < pathStartMinutes ||
|
||||
base == null ||
|
||||
!Number.isFinite(base)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const progressToEvening = Math.min(
|
||||
Math.max(
|
||||
(minute - pathStartMinutes) /
|
||||
Math.max(returnToBaselineMinute - pathStartMinutes, 1),
|
||||
0,
|
||||
),
|
||||
1,
|
||||
);
|
||||
// Strongest right after the latest observation, then smoothly fades back
|
||||
// to the unchanged DEB baseline by evening/sunset. This keeps one METAR
|
||||
// point from dragging the whole-day forecast away from the base path.
|
||||
const decay = Math.pow(1 - progressToEvening, 1.35);
|
||||
return Number((base + adjustmentDelta * decay).toFixed(1));
|
||||
});
|
||||
|
||||
return { adjustmentDelta, future };
|
||||
}
|
||||
|
||||
function sortObservationItemsByTime<T extends { time?: string | null }>(items: T[]) {
|
||||
return [...items].sort((left, right) => {
|
||||
@@ -392,51 +151,15 @@ export function getTemperatureChartData(
|
||||
const mgmHourlyRows = Array.isArray(detail.mgm?.hourly)
|
||||
? detail.mgm?.hourly || []
|
||||
: [];
|
||||
const hasPrimaryHourly =
|
||||
Array.isArray(hourly.times) &&
|
||||
Array.isArray(hourly.temps) &&
|
||||
Math.min(hourly.times.length, hourly.temps.length) > 0;
|
||||
const useMgmHourlyAsForecastBase = !hasPrimaryHourly && isTurkishMgmCity(detail);
|
||||
const rawTimes = useMgmHourlyAsForecastBase
|
||||
? mgmHourlyRows.map((row) => String(row?.time || ""))
|
||||
: Array.isArray(hourly.times)
|
||||
? hourly.times
|
||||
: [];
|
||||
const rawTemps = useMgmHourlyAsForecastBase
|
||||
? mgmHourlyRows.map((row) => row?.temp ?? null)
|
||||
: Array.isArray(hourly.temps)
|
||||
? hourly.temps
|
||||
: [];
|
||||
const validEntries = rawTimes
|
||||
.map((time, index) => ({
|
||||
tail: normalizeHm(String(time || "").trim()) || "",
|
||||
value: Number(rawTemps[index]),
|
||||
}))
|
||||
.filter((entry) => entry.tail !== "");
|
||||
const dataByHour = new Map<string, number | null>();
|
||||
validEntries.forEach((entry) => {
|
||||
dataByHour.set(entry.tail, Number.isFinite(entry.value) ? entry.value : null);
|
||||
});
|
||||
const getHourTemp = (h: number): number | null => {
|
||||
const key = `${String(h).padStart(2, "0")}:00`;
|
||||
return dataByHour.has(key) ? dataByHour.get(key)! : null;
|
||||
};
|
||||
const times: string[] = [];
|
||||
const temps: Array<number | null> = [];
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const hh = String(h).padStart(2, "0");
|
||||
times.push(`${hh}:00`);
|
||||
temps.push(getHourTemp(h));
|
||||
const a = getHourTemp(h);
|
||||
const b = h < 23 ? getHourTemp(h + 1) : null;
|
||||
times.push(`${hh}:30`);
|
||||
temps.push(a != null && b != null ? Number((a + (b - a) * 0.5).toFixed(1)) : a ?? b);
|
||||
}
|
||||
const suppressAnkaraMgmObservation = isTurkishMgmCity(detail);
|
||||
|
||||
const axis = buildChartTimeAxis(
|
||||
hourly.times,
|
||||
hourly.temps,
|
||||
mgmHourlyRows,
|
||||
isTurkishMgmCity(detail),
|
||||
);
|
||||
const { times, temps } = axis;
|
||||
if (!times.length) return null;
|
||||
|
||||
const currentIndex = findNearestTimeIndex(times, detail.local_time);
|
||||
const mgmHourlyMax = mgmHourlyRows
|
||||
.map((row) => Number(row?.temp))
|
||||
.filter((value) => Number.isFinite(value))
|
||||
@@ -444,22 +167,22 @@ export function getTemperatureChartData(
|
||||
(maxValue, value) => (maxValue == null ? value : Math.max(maxValue, value)),
|
||||
null,
|
||||
);
|
||||
const omMax = detail.forecast?.today_high ?? mgmHourlyMax;
|
||||
const debMax = detail.deb?.prediction;
|
||||
const offset =
|
||||
debMax != null && omMax != null ? Number(debMax) - Number(omMax) : 0;
|
||||
const debBaseTemps = fillTemperaturePathForFullDay(times, temps);
|
||||
const debTemps = debBaseTemps.map((temp) =>
|
||||
temp != null && Number.isFinite(temp)
|
||||
? Number((temp + offset).toFixed(1))
|
||||
: null,
|
||||
);
|
||||
const debPast = debTemps.map((temp, index) =>
|
||||
currentIndex >= 0 && index <= currentIndex ? temp : null,
|
||||
);
|
||||
const debFuture = debTemps.map((temp, index) =>
|
||||
currentIndex < 0 || index >= currentIndex ? temp : null,
|
||||
const baseline = buildDebBaselinePath(
|
||||
times,
|
||||
temps,
|
||||
detail.deb?.prediction,
|
||||
detail.local_time,
|
||||
detail.forecast?.today_high,
|
||||
mgmHourlyMax,
|
||||
);
|
||||
const {
|
||||
debTemps,
|
||||
debPast,
|
||||
debFuture,
|
||||
currentIndex,
|
||||
offset,
|
||||
} = baseline;
|
||||
const suppressAnkaraMgmObservation = isTurkishMgmCity(detail);
|
||||
|
||||
const observationTag = getRealtimeObservationTag(detail);
|
||||
const observationCode = getObservationSourceCode(detail);
|
||||
@@ -561,39 +284,18 @@ export function getTemperatureChartData(
|
||||
? `NOAA ${getNoaaStationCode(detail)}`
|
||||
: observationTag;
|
||||
|
||||
const metarPoints = new Array(times.length).fill(null);
|
||||
observationSource.forEach((item) => {
|
||||
const index = findNearestTimeIndex(times, String(item.time || ""));
|
||||
const temp = Number(item.temp);
|
||||
if (index >= 0 && Number.isFinite(temp)) {
|
||||
const existing = metarPoints[index];
|
||||
// Multiple reports can land in the same hour bucket. Keep the peak
|
||||
// value so an intrahour high is not hidden by a later weaker report.
|
||||
metarPoints[index] =
|
||||
existing == null ? temp : Math.max(Number(existing), temp);
|
||||
}
|
||||
});
|
||||
const metarPoints = buildObservationGrid(observationSource, times);
|
||||
const airportMetarPoints = new Array(times.length).fill(null);
|
||||
airportMetarSource.forEach((item) => {
|
||||
const index = findNearestTimeIndex(times, String(item.time || ""));
|
||||
const temp = Number(item.temp);
|
||||
if (index >= 0 && Number.isFinite(temp)) {
|
||||
const existing = airportMetarPoints[index];
|
||||
airportMetarPoints[index] =
|
||||
existing == null ? temp : Math.max(Number(existing), temp);
|
||||
}
|
||||
});
|
||||
const calibrationObservationSource = dedupeObservationItems(
|
||||
metarObservationSource.length ? metarObservationSource : observationSource,
|
||||
);
|
||||
const calibratedPath = buildCalibratedFuturePath({
|
||||
observations: calibrationObservationSource,
|
||||
const calibratedPath = buildCalibratedPath(
|
||||
calibrationObservationSource,
|
||||
times,
|
||||
debTemps,
|
||||
currentMinutes: hmToMinutes(detail.local_time),
|
||||
reversionMinutes:
|
||||
hmToMinutes(detail.forecast?.sunset) ?? hmToMinutes("18:00"),
|
||||
});
|
||||
detail.local_time,
|
||||
detail.forecast?.sunset,
|
||||
);
|
||||
const calibratedFuture = calibratedPath.future;
|
||||
|
||||
const mgmPoints = new Array(times.length).fill(null);
|
||||
@@ -638,41 +340,7 @@ export function getTemperatureChartData(
|
||||
const tafMarkersRaw = Array.isArray(detail.taf?.signal?.markers)
|
||||
? detail.taf?.signal?.markers || []
|
||||
: [];
|
||||
const normalizeTafHm = (value: unknown): string | null => {
|
||||
const match = String(value || "").match(/(\d{1,2}):(\d{2})/);
|
||||
if (!match) return null;
|
||||
const hour = Number.parseInt(match[1], 10);
|
||||
const minute = Number.parseInt(match[2], 10);
|
||||
if (
|
||||
!Number.isFinite(hour) ||
|
||||
!Number.isFinite(minute) ||
|
||||
hour < 0 ||
|
||||
hour > 23 ||
|
||||
minute < 0 ||
|
||||
minute > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
};
|
||||
const chartHmToMinutes = (value: string | null) => {
|
||||
if (!value) return null;
|
||||
const [hourPart, minutePart] = value.split(":");
|
||||
const hour = Number.parseInt(hourPart || "", 10);
|
||||
const minute = Number.parseInt(minutePart || "", 10);
|
||||
if (
|
||||
!Number.isFinite(hour) ||
|
||||
!Number.isFinite(minute) ||
|
||||
hour < 0 ||
|
||||
hour > 23 ||
|
||||
minute < 0 ||
|
||||
minute > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return hour * 60 + minute;
|
||||
};
|
||||
const currentMinutes = chartHmToMinutes(normalizeTafHm(detail.local_time));
|
||||
const currentMinutes = hmToMinutes(normalizeHm(detail.local_time));
|
||||
const peakFirstHour = Number(detail.peak?.first_h);
|
||||
const peakLastHour = Number(detail.peak?.last_h);
|
||||
const peakWindowStartMinutes =
|
||||
@@ -732,23 +400,23 @@ export function getTemperatureChartData(
|
||||
const currentTafMarker =
|
||||
currentMinutes !== null
|
||||
? tafMarkers.find((marker) => {
|
||||
const start = chartHmToMinutes(normalizeTafHm(marker.startLocal));
|
||||
const end = chartHmToMinutes(normalizeTafHm(marker.endLocal));
|
||||
const start = hmToMinutes(normalizeHm(marker.startLocal));
|
||||
const end = hmToMinutes(normalizeHm(marker.endLocal));
|
||||
return start !== null && end !== null && currentMinutes >= start && currentMinutes <= end;
|
||||
}) || null
|
||||
: null;
|
||||
const nextTafMarker =
|
||||
currentMinutes !== null && !currentTafMarker
|
||||
? tafMarkers.find((marker) => {
|
||||
const start = chartHmToMinutes(normalizeTafHm(marker.startLocal));
|
||||
const start = hmToMinutes(normalizeHm(marker.startLocal));
|
||||
return start !== null && start > currentMinutes;
|
||||
}) || null
|
||||
: null;
|
||||
const peakWindowTafMarker =
|
||||
peakWindowStartMinutes !== null && peakWindowEndMinutes !== null
|
||||
? tafMarkers.find((marker) => {
|
||||
const start = chartHmToMinutes(normalizeTafHm(marker.startLocal));
|
||||
const end = chartHmToMinutes(normalizeTafHm(marker.endLocal));
|
||||
const start = hmToMinutes(normalizeHm(marker.startLocal));
|
||||
const end = hmToMinutes(normalizeHm(marker.endLocal));
|
||||
return (
|
||||
start !== null &&
|
||||
end !== null &&
|
||||
@@ -790,7 +458,7 @@ export function getTemperatureChartData(
|
||||
if (!suppressAnkaraMgmObservation && detail.mgm?.temp != null) {
|
||||
legendParts.push(`MGM: ${detail.mgm.temp}${detail.temp_symbol}`);
|
||||
}
|
||||
if (!hasMgmHourly && debMax != null && omMax != null && Math.abs(offset) > 0.3) {
|
||||
if (!hasMgmHourly && Math.abs(offset) > 0.3) {
|
||||
const sign = offset > 0 ? "+" : "";
|
||||
legendParts.push(
|
||||
isEnglish(locale)
|
||||
@@ -802,17 +470,23 @@ export function getTemperatureChartData(
|
||||
const sign = calibratedPath.adjustmentDelta > 0 ? "+" : "";
|
||||
legendParts.push(
|
||||
isEnglish(locale)
|
||||
? `METAR-calibrated path applies latest observation bias ${sign}${calibratedPath.adjustmentDelta.toFixed(1)}${detail.temp_symbol}.`
|
||||
: `修正路径使用最新 METAR 偏差 ${sign}${calibratedPath.adjustmentDelta.toFixed(1)}${detail.temp_symbol}。`,
|
||||
? `DEB calibrated path applies latest observation bias ${sign}${calibratedPath.adjustmentDelta.toFixed(1)}${detail.temp_symbol}.`
|
||||
: `DEB 修正路径使用最新观测偏差 ${sign}${calibratedPath.adjustmentDelta.toFixed(1)}${detail.temp_symbol}。`,
|
||||
);
|
||||
}
|
||||
if (hasMgmHourly) {
|
||||
const hourly = detail.hourly || {};
|
||||
const hasPrimaryHourly =
|
||||
Array.isArray(hourly.times) &&
|
||||
Array.isArray(hourly.temps) &&
|
||||
Math.min(hourly.times.length, hourly.temps.length) > 0;
|
||||
const mgmIsForecastBase = !hasPrimaryHourly && isTurkishMgmCity(detail);
|
||||
legendParts.push(
|
||||
isEnglish(locale)
|
||||
? useMgmHourlyAsForecastBase
|
||||
? mgmIsForecastBase
|
||||
? "Using MGM hourly forecast as the DEB curve base"
|
||||
: "MGM hourly forecast is shown as official hourly guidance"
|
||||
: useMgmHourlyAsForecastBase
|
||||
: mgmIsForecastBase
|
||||
? "已使用 MGM 小时预报作为 DEB 曲线基底"
|
||||
: "MGM 小时预报作为官方小时指引显示",
|
||||
);
|
||||
@@ -908,17 +582,17 @@ export function getTemperatureChartData(
|
||||
const calibratedFutureSeries = buildSeriesPoints(times, calibratedFuture);
|
||||
const tempsSeries = buildSeriesPoints(times, temps);
|
||||
const mgmHourlySeries = buildSeriesPoints(times, mgmHourlyPoints);
|
||||
const metarSeries = buildObservationPoints(observationSource);
|
||||
const airportMetarSeries = buildObservationPoints(airportMetarSource);
|
||||
const metarSeries = buildObservationPointSeries(observationSource);
|
||||
const airportMetarSeries = buildObservationPointSeries(airportMetarSource);
|
||||
const mgmSeries =
|
||||
!suppressAnkaraMgmObservation && detail.mgm?.temp != null && detail.mgm?.time
|
||||
? buildObservationPoints([{ time: detail.mgm.time, temp: detail.mgm.temp }])
|
||||
? buildObservationPointSeries([{ time: detail.mgm.time, temp: detail.mgm.temp }])
|
||||
: [];
|
||||
const tafCurrentMarkerSeries = tafMarkers
|
||||
.filter((marker) => marker.isCurrent)
|
||||
.map((marker) => ({
|
||||
marker,
|
||||
x: chartHmToMinutes(marker.labelTime) ?? 0,
|
||||
x: hmToMinutes(marker.labelTime) ?? 0,
|
||||
y: tafMarkerValue,
|
||||
}))
|
||||
.filter((point) => point.x > 0);
|
||||
@@ -926,19 +600,19 @@ export function getTemperatureChartData(
|
||||
.filter((marker) => marker.isPeakWindow && !marker.isCurrent)
|
||||
.map((marker) => ({
|
||||
marker,
|
||||
x: chartHmToMinutes(marker.labelTime) ?? 0,
|
||||
x: hmToMinutes(marker.labelTime) ?? 0,
|
||||
y: tafMarkerValue - 0.15,
|
||||
}))
|
||||
.filter((point) => point.x > 0);
|
||||
const tafMarkerSeries = tafMarkers
|
||||
.map((marker) => ({
|
||||
marker,
|
||||
x: chartHmToMinutes(marker.labelTime) ?? 0,
|
||||
x: hmToMinutes(marker.labelTime) ?? 0,
|
||||
y: tafMarkerValue,
|
||||
}))
|
||||
.filter((point) => point.x > 0);
|
||||
const xMin = times.length ? chartHmToMinutes(times[0]) ?? 0 : 0;
|
||||
const xMax = times.length ? chartHmToMinutes(times[times.length - 1]) ?? 24 * 60 : 24 * 60;
|
||||
const xMin = times.length ? hmToMinutes(times[0]) ?? 0 : 0;
|
||||
const xMax = times.length ? hmToMinutes(times[times.length - 1]) ?? 24 * 60 : 24 * 60;
|
||||
|
||||
return {
|
||||
currentIndex,
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Pure functions for building temperature chart data paths.
|
||||
*
|
||||
* All functions in this module accept plain values (arrays, strings, numbers)
|
||||
* — they do NOT depend on the CityDetail type. This keeps the path-building
|
||||
* logic testable in isolation and reusable across chart consumers.
|
||||
*/
|
||||
|
||||
import { normalizeTemperatureSymbol } from "@/lib/temperature-utils";
|
||||
import {
|
||||
hmToMinutes,
|
||||
interpolateSeriesAtMinutes,
|
||||
normalizeHm,
|
||||
} from "@/lib/time-utils";
|
||||
|
||||
// ── small helpers ──────────────────────────────────────────────
|
||||
|
||||
export function clampTemperatureDelta(value: number, min = -4, max = 4) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function findNearestTimeIndex(
|
||||
times: string[],
|
||||
targetTime?: string | null,
|
||||
) {
|
||||
const targetMinutes = hmToMinutes(targetTime);
|
||||
if (targetMinutes == null || !times.length) return -1;
|
||||
let nearestIndex = -1;
|
||||
let nearestDelta = Number.POSITIVE_INFINITY;
|
||||
times.forEach((time, index) => {
|
||||
const minute = hmToMinutes(time);
|
||||
if (minute == null) return;
|
||||
const delta = Math.abs(minute - targetMinutes);
|
||||
if (delta < nearestDelta) {
|
||||
nearestDelta = delta;
|
||||
nearestIndex = index;
|
||||
}
|
||||
});
|
||||
return nearestIndex;
|
||||
}
|
||||
|
||||
export function buildTemperatureTickLabels(times: string[]) {
|
||||
const lastIndex = Math.max(0, times.length - 1);
|
||||
return times.map((time, index) => {
|
||||
if (index === 0 || index === lastIndex) return time;
|
||||
const minute = hmToMinutes(time);
|
||||
if (minute == null) return "";
|
||||
const hour = Math.floor(minute / 60);
|
||||
const minutePart = minute % 60;
|
||||
if (minutePart !== 0) return "";
|
||||
return hour % 2 === 0 ? time : "";
|
||||
});
|
||||
}
|
||||
|
||||
export function getNiceTemperatureScale(
|
||||
values: number[],
|
||||
tempSymbol?: string | null,
|
||||
) {
|
||||
const numericValues = values.filter((value) => Number.isFinite(Number(value)));
|
||||
if (!numericValues.length) {
|
||||
return { max: 1, min: 0, step: 1 };
|
||||
}
|
||||
|
||||
const rawMin = Math.min(...numericValues);
|
||||
const rawMax = Math.max(...numericValues);
|
||||
const spread = Math.max(0.1, rawMax - rawMin);
|
||||
const isFahrenheit = normalizeTemperatureSymbol(tempSymbol) === "°F";
|
||||
const padding = Math.max(isFahrenheit ? 1.5 : 0.8, spread * 0.12);
|
||||
const paddedMin = rawMin - padding;
|
||||
const paddedMax = rawMax + padding;
|
||||
const paddedSpread = Math.max(0.1, paddedMax - paddedMin);
|
||||
const candidates = isFahrenheit ? [1, 2, 5, 10, 20] : [0.5, 1, 2, 5, 10];
|
||||
let step =
|
||||
candidates.find((candidate) => candidate >= paddedSpread / 4) ||
|
||||
candidates[candidates.length - 1];
|
||||
let min = Math.floor(paddedMin / step) * step;
|
||||
let max = Math.ceil(paddedMax / step) * step;
|
||||
|
||||
if (min < 0 && rawMin >= 0 && rawMin <= step * 1.25) min = 0;
|
||||
if (max <= min) max = min + step * 4;
|
||||
|
||||
while ((max - min) / step + 1 > 6) {
|
||||
const nextStep = candidates.find((candidate) => candidate > step);
|
||||
if (!nextStep) break;
|
||||
step = nextStep;
|
||||
min = Math.floor(paddedMin / step) * step;
|
||||
max = Math.ceil(paddedMax / step) * step;
|
||||
if (min < 0 && rawMin >= 0 && rawMin <= step * 1.25) min = 0;
|
||||
}
|
||||
|
||||
return { max, min, step };
|
||||
}
|
||||
|
||||
export function buildSeriesPoints(
|
||||
times: string[],
|
||||
values: Array<number | null | undefined>,
|
||||
) {
|
||||
return times
|
||||
.map((time, index) => {
|
||||
const x = hmToMinutes(time);
|
||||
const y = values[index];
|
||||
return x != null && y != null && Number.isFinite(Number(y))
|
||||
? { index, labelTime: time, x, y: Number(y) }
|
||||
: null;
|
||||
})
|
||||
.filter(
|
||||
(point): point is { index: number; labelTime: string; x: number; y: number } =>
|
||||
point != null,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildObservationPointSeries(
|
||||
items: Array<{ time?: string; temp?: number | null }>,
|
||||
) {
|
||||
return items
|
||||
.map((item) => {
|
||||
const labelTime = normalizeHm(String(item.time || ""));
|
||||
const x = hmToMinutes(labelTime);
|
||||
const y = item.temp;
|
||||
return x != null && y != null && Number.isFinite(Number(y))
|
||||
? { labelTime: labelTime || "", x, y: Number(y) }
|
||||
: null;
|
||||
})
|
||||
.filter((point): point is { labelTime: string; x: number; y: number } => point != null);
|
||||
}
|
||||
|
||||
// ── hour grid ──────────────────────────────────────────────────
|
||||
|
||||
export interface ChartTimeAxis {
|
||||
times: string[];
|
||||
temps: Array<number | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 48-point intraday time axis (00:00 … 23:30) from an hourly
|
||||
* forecast series. When the primary hourly series is empty AND the city
|
||||
* uses MGM as its forecast source, ``mgmHourly`` rows are used instead.
|
||||
*/
|
||||
export function buildChartTimeAxis(
|
||||
hourlyTimes: string[] | null | undefined,
|
||||
hourlyTemps: Array<number | null> | null | undefined,
|
||||
mgmHourlyRows: Array<{ time?: string | null; temp?: number | null }> | null | undefined,
|
||||
isTurkishMgm: boolean,
|
||||
): ChartTimeAxis {
|
||||
const primaryTimes = Array.isArray(hourlyTimes) ? hourlyTimes : [];
|
||||
const primaryTemps = Array.isArray(hourlyTemps) ? hourlyTemps : [];
|
||||
const hasPrimary =
|
||||
primaryTimes.length > 0 &&
|
||||
primaryTemps.length > 0 &&
|
||||
Math.min(primaryTimes.length, primaryTemps.length) > 0;
|
||||
|
||||
const mgmRows = Array.isArray(mgmHourlyRows) ? mgmHourlyRows : [];
|
||||
const useMgm = !hasPrimary && isTurkishMgm;
|
||||
|
||||
const rawTimes: string[] = useMgm
|
||||
? mgmRows.map((row) => String(row?.time || ""))
|
||||
: primaryTimes;
|
||||
const rawTemps: Array<number | null> = useMgm
|
||||
? mgmRows.map((row) => row?.temp ?? null)
|
||||
: primaryTemps;
|
||||
|
||||
const dataByHour = new Map<string, number | null>();
|
||||
rawTimes.forEach((raw, i) => {
|
||||
const tail = normalizeHm(String(raw || "").trim()) || "";
|
||||
if (!tail) return;
|
||||
const value = Number(rawTemps[i]);
|
||||
dataByHour.set(tail, Number.isFinite(value) ? value : null);
|
||||
});
|
||||
|
||||
const getHourTemp = (h: number): number | null => {
|
||||
const key = `${String(h).padStart(2, "0")}:00`;
|
||||
return dataByHour.has(key) ? dataByHour.get(key)! : null;
|
||||
};
|
||||
|
||||
const times: string[] = [];
|
||||
const temps: Array<number | null> = [];
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const hh = String(h).padStart(2, "0");
|
||||
times.push(`${hh}:00`);
|
||||
temps.push(getHourTemp(h));
|
||||
if (h < 23) {
|
||||
const a = getHourTemp(h);
|
||||
const b = getHourTemp(h + 1);
|
||||
times.push(`${hh}:30`);
|
||||
temps.push(
|
||||
a != null && b != null
|
||||
? Number((a + (b - a) * 0.5).toFixed(1))
|
||||
: a ?? b,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { times, temps };
|
||||
}
|
||||
|
||||
// ── DEB baseline path ──────────────────────────────────────────
|
||||
|
||||
export function fillTemperaturePathForFullDay(
|
||||
times: string[],
|
||||
values: Array<number | null>,
|
||||
) {
|
||||
if (!times.length) return values;
|
||||
const hasAnyValue = values.some((v) => v != null && Number.isFinite(v));
|
||||
if (!hasAnyValue) return values;
|
||||
return times.map((time, index) => {
|
||||
const value = values[index];
|
||||
if (value != null && Number.isFinite(value)) return value;
|
||||
const minute = hmToMinutes(time);
|
||||
if (minute == null) return null;
|
||||
const interpolated = interpolateSeriesAtMinutes(times, values, minute);
|
||||
return interpolated != null && Number.isFinite(interpolated)
|
||||
? interpolated
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
export interface DebBaselinePath {
|
||||
/** Full 48-point DEB baseline (past + future merged). */
|
||||
debTemps: Array<number | null>;
|
||||
/** Past portion (solid line). */
|
||||
debPast: Array<number | null>;
|
||||
/** Future portion (dashed line). */
|
||||
debFuture: Array<number | null>;
|
||||
/** Index of current local time in the time axis, or -1. */
|
||||
currentIndex: number;
|
||||
/** Offset applied to the hourly curve to align with DEB prediction. */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the DEB baseline path by shifting the hourly forecast curve so
|
||||
* that its peak aligns with the DEB daily-high prediction.
|
||||
*
|
||||
* **Offset base priority:**
|
||||
* 1. The hourly curve's own maximum temperature
|
||||
* 2. ``forecastTodayHigh`` (Open-Meteo daily high) — only when hourly
|
||||
* data is completely absent
|
||||
* 3. ``mgmHourlyMax`` — only when both hourly and forecast are absent
|
||||
*
|
||||
* This prevents a stale or unreliable ``forecast.today_high`` from
|
||||
* pushing/pulling the entire curve by an unrealistic offset (e.g. Moscow:
|
||||
* forecast.today_high=21.4, hourly max=24.7, DEB=24.5 → old offset=+3.1,
|
||||
* new offset=+0.2).
|
||||
*/
|
||||
export function buildDebBaselinePath(
|
||||
times: string[],
|
||||
hourlyTemps: Array<number | null>,
|
||||
debPrediction: number | null | undefined,
|
||||
localTime: string | null | undefined,
|
||||
forecastTodayHigh?: number | null,
|
||||
mgmHourlyMax?: number | null,
|
||||
): DebBaselinePath {
|
||||
const currentIndex = findNearestTimeIndex(times, localTime);
|
||||
const debMax = Number(debPrediction);
|
||||
const hasDebMax = Number.isFinite(debMax);
|
||||
|
||||
// Hourly curve's own max — preferred offset base
|
||||
const hourlyMax =
|
||||
hourlyTemps.length > 0
|
||||
? hourlyTemps.reduce<number | null>(
|
||||
(max, v) =>
|
||||
v != null && Number.isFinite(v)
|
||||
? max == null
|
||||
? v
|
||||
: Math.max(max, v)
|
||||
: max,
|
||||
null,
|
||||
)
|
||||
: null;
|
||||
|
||||
const omMax =
|
||||
(hourlyMax != null && Number.isFinite(hourlyMax) ? hourlyMax : null) ??
|
||||
(forecastTodayHigh != null && Number.isFinite(forecastTodayHigh)
|
||||
? Number(forecastTodayHigh)
|
||||
: null) ??
|
||||
(mgmHourlyMax != null && Number.isFinite(mgmHourlyMax)
|
||||
? Number(mgmHourlyMax)
|
||||
: null);
|
||||
|
||||
const offset =
|
||||
hasDebMax && omMax != null ? debMax - omMax : 0;
|
||||
|
||||
// Fill gaps with interpolation, then apply DEB offset
|
||||
const filled = fillTemperaturePathForFullDay(times, hourlyTemps);
|
||||
const debTemps: Array<number | null> = filled.map((temp) =>
|
||||
temp != null && Number.isFinite(temp)
|
||||
? Number((temp + offset).toFixed(1))
|
||||
: null,
|
||||
);
|
||||
|
||||
const debPast = debTemps.map((t, i) =>
|
||||
currentIndex >= 0 && i <= currentIndex ? t : null,
|
||||
);
|
||||
const debFuture = debTemps.map((t, i) =>
|
||||
currentIndex < 0 || i >= currentIndex ? t : null,
|
||||
);
|
||||
|
||||
return { debTemps, debPast, debFuture, currentIndex, offset };
|
||||
}
|
||||
|
||||
// ── calibrated "DEB corrected" path ────────────────────────────
|
||||
|
||||
export interface CalibratedPathResult {
|
||||
adjustmentDelta: number | null;
|
||||
future: Array<number | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust the future portion of the DEB baseline using the most recent
|
||||
* METAR observations. The adjustment fades smoothly back to the DEB
|
||||
* baseline by evening (sunset or 18:00).
|
||||
*/
|
||||
export function buildCalibratedPath(
|
||||
observations: Array<{ time?: string | null; temp?: number | null }>,
|
||||
times: string[],
|
||||
debTemps: Array<number | null>,
|
||||
localTime: string | null | undefined,
|
||||
sunset?: string | null,
|
||||
): CalibratedPathResult {
|
||||
if (!times.length || !observations.length) {
|
||||
return { adjustmentDelta: null, future: new Array(times.length).fill(null) };
|
||||
}
|
||||
|
||||
// Deduplicate by time, keeping the highest temp per slot
|
||||
const byTime = new Map<string, { time: string; temp: number }>();
|
||||
for (const item of observations) {
|
||||
const time = normalizeHm(item.time);
|
||||
const value = Number(item.temp);
|
||||
if (!time || !Number.isFinite(value)) continue;
|
||||
const existing = byTime.get(time);
|
||||
if (!existing || value >= existing.temp) {
|
||||
byTime.set(time, { time, temp: value });
|
||||
}
|
||||
}
|
||||
const unique = [...byTime.values()].sort((a, b) => {
|
||||
const am = hmToMinutes(a.time) ?? 0;
|
||||
const bm = hmToMinutes(b.time) ?? 0;
|
||||
return am - bm;
|
||||
});
|
||||
|
||||
const currentMinutes = hmToMinutes(localTime);
|
||||
|
||||
const latestObsMinute = unique.reduce<number | null>(
|
||||
(latest, item) => {
|
||||
const minute = hmToMinutes(item.time);
|
||||
if (minute == null) return latest;
|
||||
return latest == null ? minute : Math.max(latest, minute);
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
if (latestObsMinute == null && currentMinutes == null) {
|
||||
return { adjustmentDelta: null, future: new Array(times.length).fill(null) };
|
||||
}
|
||||
|
||||
const pathStartMinutes =
|
||||
latestObsMinute == null || currentMinutes == null
|
||||
? latestObsMinute ?? currentMinutes ?? 0
|
||||
: Math.max(currentMinutes, latestObsMinute);
|
||||
|
||||
// Keep the last 3 deltas before the path start
|
||||
const deltas = unique
|
||||
.map((item) => {
|
||||
const minute = hmToMinutes(item.time);
|
||||
const observed = item.temp;
|
||||
if (minute == null || minute > pathStartMinutes + 30 || !Number.isFinite(observed))
|
||||
return null;
|
||||
const expected = interpolateSeriesAtMinutes(times, debTemps, minute);
|
||||
if (expected == null || !Number.isFinite(expected)) return null;
|
||||
return {
|
||||
delta: clampTemperatureDelta(observed - expected),
|
||||
minute,
|
||||
};
|
||||
})
|
||||
.filter((d): d is { delta: number; minute: number } => d != null)
|
||||
.slice(-3);
|
||||
|
||||
if (!deltas.length) {
|
||||
return { adjustmentDelta: null, future: new Array(times.length).fill(null) };
|
||||
}
|
||||
|
||||
// Weighted average — more recent = higher weight
|
||||
const weighted = deltas.reduce(
|
||||
(acc, item, index) => ({
|
||||
total: acc.total + item.delta * (index + 1),
|
||||
weight: acc.weight + (index + 1),
|
||||
}),
|
||||
{ total: 0, weight: 0 },
|
||||
);
|
||||
const adjustmentDelta = Number(
|
||||
clampTemperatureDelta(weighted.total / Math.max(weighted.weight, 1)).toFixed(1),
|
||||
);
|
||||
|
||||
const lastSeriesMinute = times
|
||||
.map((t) => hmToMinutes(t))
|
||||
.filter((m): m is number => m != null)
|
||||
.at(-1);
|
||||
const reversionMinutes = hmToMinutes(sunset) ?? hmToMinutes("18:00");
|
||||
const returnToBaselineMinute =
|
||||
reversionMinutes != null && reversionMinutes > pathStartMinutes
|
||||
? reversionMinutes
|
||||
: lastSeriesMinute != null && lastSeriesMinute > pathStartMinutes
|
||||
? lastSeriesMinute
|
||||
: pathStartMinutes + 6 * 60;
|
||||
|
||||
const future = times.map((time, index) => {
|
||||
const minute = hmToMinutes(time);
|
||||
const base = debTemps[index];
|
||||
if (minute == null || minute < pathStartMinutes || base == null || !Number.isFinite(base))
|
||||
return null;
|
||||
const progressToEvening = Math.min(
|
||||
Math.max((minute - pathStartMinutes) / Math.max(returnToBaselineMinute - pathStartMinutes, 1), 0),
|
||||
1,
|
||||
);
|
||||
const decay = Math.pow(1 - progressToEvening, 1.35);
|
||||
return Number((base + adjustmentDelta * decay).toFixed(1));
|
||||
});
|
||||
|
||||
return { adjustmentDelta, future };
|
||||
}
|
||||
|
||||
// ── observation points ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map observation items onto the 48-point time grid.
|
||||
* Multiple observations landing in the same slot keep the highest temp.
|
||||
*/
|
||||
export function buildObservationGrid(
|
||||
source: Array<{ time?: string | null; temp?: number | null }>,
|
||||
times: string[],
|
||||
): Array<number | null> {
|
||||
const points = new Array(times.length).fill(null) as Array<number | null>;
|
||||
for (const item of source) {
|
||||
const index = findNearestTimeIndex(times, String(item.time || ""));
|
||||
const temp = Number(item.temp);
|
||||
if (index >= 0 && Number.isFinite(temp)) {
|
||||
const existing = points[index];
|
||||
points[index] = existing == null ? temp : Math.max(existing, temp);
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
Reference in New Issue
Block a user