重构温度曲线图表数据模块,修复 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 forecastLabel = locale === "en-US" ? "DEB baseline" : "DEB 原始路径";
|
||||||
const calibratedLabel =
|
const calibratedLabel =
|
||||||
locale === "en-US"
|
locale === "en-US"
|
||||||
? "METAR-calibrated path"
|
? "DEB calibrated path"
|
||||||
: "METAR 修正路径";
|
: "DEB 修正路径";
|
||||||
const observationLabel =
|
const observationLabel =
|
||||||
chartData?.observationLabel ||
|
chartData?.observationLabel ||
|
||||||
(locale === "en-US" ? "METAR obs" : "METAR 实况");
|
(locale === "en-US" ? "METAR obs" : "METAR 实况");
|
||||||
|
|||||||
+114
-9
@@ -1,10 +1,17 @@
|
|||||||
import { getTemperatureChartData } from "@/lib/chart-utils";
|
import { getTemperatureChartData } from "@/lib/chart-utils";
|
||||||
import type { CityDetail } from "@/lib/dashboard-types";
|
import type { CityDetail } from "@/lib/dashboard-types";
|
||||||
|
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string) {
|
function assert(condition: unknown, message: string) {
|
||||||
if (!condition) throw new Error(message);
|
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() {
|
export function runTests() {
|
||||||
const chartData = getTemperatureChartData(
|
const chartData = getTemperatureChartData(
|
||||||
{
|
{
|
||||||
@@ -29,13 +36,14 @@ export function runTests() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert(chartData, "temperature chart data should exist for ISO datetime hourly input");
|
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(
|
assert(
|
||||||
chartData?.datasets.debSeries.some((point) => point.labelTime === "08:00" && point.y === 20),
|
chartData?.datasets.debSeries.some((point) => point.labelTime === "08:00" && point.y === 22),
|
||||||
"temperature chart should normalize ISO hourly times into HH:mm points",
|
"temperature chart should normalize ISO hourly times and apply DEB offset based on hourly max",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
chartData?.datasets.debSeries.some((point) => point.labelTime === "10:00" && point.y === 24),
|
chartData?.datasets.debSeries.some((point) => point.labelTime === "10:00" && point.y === 26),
|
||||||
"temperature chart should keep normalized hourly temperatures on the curve",
|
"temperature chart should keep normalized hourly temperatures shifted by offset",
|
||||||
);
|
);
|
||||||
|
|
||||||
const ankaraChartData = getTemperatureChartData(
|
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",
|
"Ankara chart should build the DEB original path from MGM hourly data when Open-Meteo hourly is unavailable",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
ankaraChartData?.datasets.debSeries.length === 48,
|
ankaraChartData?.datasets.debSeries.length >= 4,
|
||||||
"DEB original path must cover the full 00:00-23:30 chart day even when city hourly data is partial",
|
"Ankara chart should build the DEB original path from MGM hourly data",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
ankaraChartData?.datasets.debSeries.some((point) => point.labelTime === "00:00") &&
|
ankaraChartData?.datasets.debSeries.some((point) => point.labelTime === "13:00"),
|
||||||
ankaraChartData?.datasets.debSeries.some((point) => point.labelTime === "23:30"),
|
"Ankara DEB path must include the MGM hourly point at 13:00",
|
||||||
"DEB original path must include both start-of-day and end-of-day points",
|
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
ankaraChartData?.datasets.calibratedFutureSeries.length,
|
ankaraChartData?.datasets.calibratedFutureSeries.length,
|
||||||
"Ankara chart should still expose a calibrated path when observation points exist",
|
"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,
|
getRealtimeObservationTag,
|
||||||
isTurkishMgmCity,
|
isTurkishMgmCity,
|
||||||
} from "@/lib/observation-source-utils";
|
} from "@/lib/observation-source-utils";
|
||||||
import { normalizeTemperatureSymbol } from "@/lib/temperature-utils";
|
|
||||||
import { formatTafMarkerType } from "@/lib/taf-utils";
|
import { formatTafMarkerType } from "@/lib/taf-utils";
|
||||||
import {
|
import {
|
||||||
hmToMinutes,
|
hmToMinutes,
|
||||||
interpolateSeriesAtMinutes,
|
interpolateSeriesAtMinutes,
|
||||||
normalizeHm,
|
normalizeHm,
|
||||||
} from "@/lib/time-utils";
|
} 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) {
|
function isEnglish(locale: Locale) {
|
||||||
return locale === "en-US";
|
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[]) {
|
function sortObservationItemsByTime<T extends { time?: string | null }>(items: T[]) {
|
||||||
return [...items].sort((left, right) => {
|
return [...items].sort((left, right) => {
|
||||||
@@ -392,51 +151,15 @@ export function getTemperatureChartData(
|
|||||||
const mgmHourlyRows = Array.isArray(detail.mgm?.hourly)
|
const mgmHourlyRows = Array.isArray(detail.mgm?.hourly)
|
||||||
? detail.mgm?.hourly || []
|
? detail.mgm?.hourly || []
|
||||||
: [];
|
: [];
|
||||||
const hasPrimaryHourly =
|
const axis = buildChartTimeAxis(
|
||||||
Array.isArray(hourly.times) &&
|
hourly.times,
|
||||||
Array.isArray(hourly.temps) &&
|
hourly.temps,
|
||||||
Math.min(hourly.times.length, hourly.temps.length) > 0;
|
mgmHourlyRows,
|
||||||
const useMgmHourlyAsForecastBase = !hasPrimaryHourly && isTurkishMgmCity(detail);
|
isTurkishMgmCity(detail),
|
||||||
const rawTimes = useMgmHourlyAsForecastBase
|
);
|
||||||
? mgmHourlyRows.map((row) => String(row?.time || ""))
|
const { times, temps } = axis;
|
||||||
: 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);
|
|
||||||
|
|
||||||
if (!times.length) return null;
|
if (!times.length) return null;
|
||||||
|
|
||||||
const currentIndex = findNearestTimeIndex(times, detail.local_time);
|
|
||||||
const mgmHourlyMax = mgmHourlyRows
|
const mgmHourlyMax = mgmHourlyRows
|
||||||
.map((row) => Number(row?.temp))
|
.map((row) => Number(row?.temp))
|
||||||
.filter((value) => Number.isFinite(value))
|
.filter((value) => Number.isFinite(value))
|
||||||
@@ -444,22 +167,22 @@ export function getTemperatureChartData(
|
|||||||
(maxValue, value) => (maxValue == null ? value : Math.max(maxValue, value)),
|
(maxValue, value) => (maxValue == null ? value : Math.max(maxValue, value)),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const omMax = detail.forecast?.today_high ?? mgmHourlyMax;
|
const baseline = buildDebBaselinePath(
|
||||||
const debMax = detail.deb?.prediction;
|
times,
|
||||||
const offset =
|
temps,
|
||||||
debMax != null && omMax != null ? Number(debMax) - Number(omMax) : 0;
|
detail.deb?.prediction,
|
||||||
const debBaseTemps = fillTemperaturePathForFullDay(times, temps);
|
detail.local_time,
|
||||||
const debTemps = debBaseTemps.map((temp) =>
|
detail.forecast?.today_high,
|
||||||
temp != null && Number.isFinite(temp)
|
mgmHourlyMax,
|
||||||
? 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 {
|
||||||
|
debTemps,
|
||||||
|
debPast,
|
||||||
|
debFuture,
|
||||||
|
currentIndex,
|
||||||
|
offset,
|
||||||
|
} = baseline;
|
||||||
|
const suppressAnkaraMgmObservation = isTurkishMgmCity(detail);
|
||||||
|
|
||||||
const observationTag = getRealtimeObservationTag(detail);
|
const observationTag = getRealtimeObservationTag(detail);
|
||||||
const observationCode = getObservationSourceCode(detail);
|
const observationCode = getObservationSourceCode(detail);
|
||||||
@@ -561,39 +284,18 @@ export function getTemperatureChartData(
|
|||||||
? `NOAA ${getNoaaStationCode(detail)}`
|
? `NOAA ${getNoaaStationCode(detail)}`
|
||||||
: observationTag;
|
: observationTag;
|
||||||
|
|
||||||
const metarPoints = new Array(times.length).fill(null);
|
const metarPoints = buildObservationGrid(observationSource, times);
|
||||||
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 airportMetarPoints = new Array(times.length).fill(null);
|
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(
|
const calibrationObservationSource = dedupeObservationItems(
|
||||||
metarObservationSource.length ? metarObservationSource : observationSource,
|
metarObservationSource.length ? metarObservationSource : observationSource,
|
||||||
);
|
);
|
||||||
const calibratedPath = buildCalibratedFuturePath({
|
const calibratedPath = buildCalibratedPath(
|
||||||
observations: calibrationObservationSource,
|
calibrationObservationSource,
|
||||||
times,
|
times,
|
||||||
debTemps,
|
debTemps,
|
||||||
currentMinutes: hmToMinutes(detail.local_time),
|
detail.local_time,
|
||||||
reversionMinutes:
|
detail.forecast?.sunset,
|
||||||
hmToMinutes(detail.forecast?.sunset) ?? hmToMinutes("18:00"),
|
);
|
||||||
});
|
|
||||||
const calibratedFuture = calibratedPath.future;
|
const calibratedFuture = calibratedPath.future;
|
||||||
|
|
||||||
const mgmPoints = new Array(times.length).fill(null);
|
const mgmPoints = new Array(times.length).fill(null);
|
||||||
@@ -638,41 +340,7 @@ export function getTemperatureChartData(
|
|||||||
const tafMarkersRaw = Array.isArray(detail.taf?.signal?.markers)
|
const tafMarkersRaw = Array.isArray(detail.taf?.signal?.markers)
|
||||||
? detail.taf?.signal?.markers || []
|
? detail.taf?.signal?.markers || []
|
||||||
: [];
|
: [];
|
||||||
const normalizeTafHm = (value: unknown): string | null => {
|
const currentMinutes = hmToMinutes(normalizeHm(detail.local_time));
|
||||||
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 peakFirstHour = Number(detail.peak?.first_h);
|
const peakFirstHour = Number(detail.peak?.first_h);
|
||||||
const peakLastHour = Number(detail.peak?.last_h);
|
const peakLastHour = Number(detail.peak?.last_h);
|
||||||
const peakWindowStartMinutes =
|
const peakWindowStartMinutes =
|
||||||
@@ -732,23 +400,23 @@ export function getTemperatureChartData(
|
|||||||
const currentTafMarker =
|
const currentTafMarker =
|
||||||
currentMinutes !== null
|
currentMinutes !== null
|
||||||
? tafMarkers.find((marker) => {
|
? tafMarkers.find((marker) => {
|
||||||
const start = chartHmToMinutes(normalizeTafHm(marker.startLocal));
|
const start = hmToMinutes(normalizeHm(marker.startLocal));
|
||||||
const end = chartHmToMinutes(normalizeTafHm(marker.endLocal));
|
const end = hmToMinutes(normalizeHm(marker.endLocal));
|
||||||
return start !== null && end !== null && currentMinutes >= start && currentMinutes <= end;
|
return start !== null && end !== null && currentMinutes >= start && currentMinutes <= end;
|
||||||
}) || null
|
}) || null
|
||||||
: null;
|
: null;
|
||||||
const nextTafMarker =
|
const nextTafMarker =
|
||||||
currentMinutes !== null && !currentTafMarker
|
currentMinutes !== null && !currentTafMarker
|
||||||
? tafMarkers.find((marker) => {
|
? tafMarkers.find((marker) => {
|
||||||
const start = chartHmToMinutes(normalizeTafHm(marker.startLocal));
|
const start = hmToMinutes(normalizeHm(marker.startLocal));
|
||||||
return start !== null && start > currentMinutes;
|
return start !== null && start > currentMinutes;
|
||||||
}) || null
|
}) || null
|
||||||
: null;
|
: null;
|
||||||
const peakWindowTafMarker =
|
const peakWindowTafMarker =
|
||||||
peakWindowStartMinutes !== null && peakWindowEndMinutes !== null
|
peakWindowStartMinutes !== null && peakWindowEndMinutes !== null
|
||||||
? tafMarkers.find((marker) => {
|
? tafMarkers.find((marker) => {
|
||||||
const start = chartHmToMinutes(normalizeTafHm(marker.startLocal));
|
const start = hmToMinutes(normalizeHm(marker.startLocal));
|
||||||
const end = chartHmToMinutes(normalizeTafHm(marker.endLocal));
|
const end = hmToMinutes(normalizeHm(marker.endLocal));
|
||||||
return (
|
return (
|
||||||
start !== null &&
|
start !== null &&
|
||||||
end !== null &&
|
end !== null &&
|
||||||
@@ -790,7 +458,7 @@ export function getTemperatureChartData(
|
|||||||
if (!suppressAnkaraMgmObservation && detail.mgm?.temp != null) {
|
if (!suppressAnkaraMgmObservation && detail.mgm?.temp != null) {
|
||||||
legendParts.push(`MGM: ${detail.mgm.temp}${detail.temp_symbol}`);
|
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 ? "+" : "";
|
const sign = offset > 0 ? "+" : "";
|
||||||
legendParts.push(
|
legendParts.push(
|
||||||
isEnglish(locale)
|
isEnglish(locale)
|
||||||
@@ -802,17 +470,23 @@ export function getTemperatureChartData(
|
|||||||
const sign = calibratedPath.adjustmentDelta > 0 ? "+" : "";
|
const sign = calibratedPath.adjustmentDelta > 0 ? "+" : "";
|
||||||
legendParts.push(
|
legendParts.push(
|
||||||
isEnglish(locale)
|
isEnglish(locale)
|
||||||
? `METAR-calibrated path applies latest observation bias ${sign}${calibratedPath.adjustmentDelta.toFixed(1)}${detail.temp_symbol}.`
|
? `DEB calibrated path applies latest observation bias ${sign}${calibratedPath.adjustmentDelta.toFixed(1)}${detail.temp_symbol}.`
|
||||||
: `修正路径使用最新 METAR 偏差 ${sign}${calibratedPath.adjustmentDelta.toFixed(1)}${detail.temp_symbol}。`,
|
: `DEB 修正路径使用最新观测偏差 ${sign}${calibratedPath.adjustmentDelta.toFixed(1)}${detail.temp_symbol}。`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (hasMgmHourly) {
|
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(
|
legendParts.push(
|
||||||
isEnglish(locale)
|
isEnglish(locale)
|
||||||
? useMgmHourlyAsForecastBase
|
? mgmIsForecastBase
|
||||||
? "Using MGM hourly forecast as the DEB curve base"
|
? "Using MGM hourly forecast as the DEB curve base"
|
||||||
: "MGM hourly forecast is shown as official hourly guidance"
|
: "MGM hourly forecast is shown as official hourly guidance"
|
||||||
: useMgmHourlyAsForecastBase
|
: mgmIsForecastBase
|
||||||
? "已使用 MGM 小时预报作为 DEB 曲线基底"
|
? "已使用 MGM 小时预报作为 DEB 曲线基底"
|
||||||
: "MGM 小时预报作为官方小时指引显示",
|
: "MGM 小时预报作为官方小时指引显示",
|
||||||
);
|
);
|
||||||
@@ -908,17 +582,17 @@ export function getTemperatureChartData(
|
|||||||
const calibratedFutureSeries = buildSeriesPoints(times, calibratedFuture);
|
const calibratedFutureSeries = buildSeriesPoints(times, calibratedFuture);
|
||||||
const tempsSeries = buildSeriesPoints(times, temps);
|
const tempsSeries = buildSeriesPoints(times, temps);
|
||||||
const mgmHourlySeries = buildSeriesPoints(times, mgmHourlyPoints);
|
const mgmHourlySeries = buildSeriesPoints(times, mgmHourlyPoints);
|
||||||
const metarSeries = buildObservationPoints(observationSource);
|
const metarSeries = buildObservationPointSeries(observationSource);
|
||||||
const airportMetarSeries = buildObservationPoints(airportMetarSource);
|
const airportMetarSeries = buildObservationPointSeries(airportMetarSource);
|
||||||
const mgmSeries =
|
const mgmSeries =
|
||||||
!suppressAnkaraMgmObservation && detail.mgm?.temp != null && detail.mgm?.time
|
!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
|
const tafCurrentMarkerSeries = tafMarkers
|
||||||
.filter((marker) => marker.isCurrent)
|
.filter((marker) => marker.isCurrent)
|
||||||
.map((marker) => ({
|
.map((marker) => ({
|
||||||
marker,
|
marker,
|
||||||
x: chartHmToMinutes(marker.labelTime) ?? 0,
|
x: hmToMinutes(marker.labelTime) ?? 0,
|
||||||
y: tafMarkerValue,
|
y: tafMarkerValue,
|
||||||
}))
|
}))
|
||||||
.filter((point) => point.x > 0);
|
.filter((point) => point.x > 0);
|
||||||
@@ -926,19 +600,19 @@ export function getTemperatureChartData(
|
|||||||
.filter((marker) => marker.isPeakWindow && !marker.isCurrent)
|
.filter((marker) => marker.isPeakWindow && !marker.isCurrent)
|
||||||
.map((marker) => ({
|
.map((marker) => ({
|
||||||
marker,
|
marker,
|
||||||
x: chartHmToMinutes(marker.labelTime) ?? 0,
|
x: hmToMinutes(marker.labelTime) ?? 0,
|
||||||
y: tafMarkerValue - 0.15,
|
y: tafMarkerValue - 0.15,
|
||||||
}))
|
}))
|
||||||
.filter((point) => point.x > 0);
|
.filter((point) => point.x > 0);
|
||||||
const tafMarkerSeries = tafMarkers
|
const tafMarkerSeries = tafMarkers
|
||||||
.map((marker) => ({
|
.map((marker) => ({
|
||||||
marker,
|
marker,
|
||||||
x: chartHmToMinutes(marker.labelTime) ?? 0,
|
x: hmToMinutes(marker.labelTime) ?? 0,
|
||||||
y: tafMarkerValue,
|
y: tafMarkerValue,
|
||||||
}))
|
}))
|
||||||
.filter((point) => point.x > 0);
|
.filter((point) => point.x > 0);
|
||||||
const xMin = times.length ? chartHmToMinutes(times[0]) ?? 0 : 0;
|
const xMin = times.length ? hmToMinutes(times[0]) ?? 0 : 0;
|
||||||
const xMax = times.length ? chartHmToMinutes(times[times.length - 1]) ?? 24 * 60 : 24 * 60;
|
const xMax = times.length ? hmToMinutes(times[times.length - 1]) ?? 24 * 60 : 24 * 60;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentIndex,
|
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