重构温度曲线图表数据模块,修复 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:
2569718930@qq.com
2026-05-17 20:57:52 +08:00
parent 9e400a3802
commit deec8221ea
4 changed files with 624 additions and 403 deletions
@@ -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 实况");
@@ -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
View File
@@ -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,
+442
View File
@@ -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;
}