From deec8221ea5845d63663d1f532c7d822493e8b9d Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 17 May 2026 20:57:52 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=B8=A9=E5=BA=A6=E6=9B=B2?= =?UTF-8?q?=E7=BA=BF=E5=9B=BE=E8=A1=A8=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=20DEB=20offset=20=E5=9F=BA=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 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 个测试场景 --- .../scan-terminal/AiCityTemperatureChart.tsx | 4 +- .../__tests__/temperatureChartData.test.ts | 123 ++++- frontend/lib/chart-utils.ts | 458 +++--------------- frontend/lib/temperature-chart-paths.ts | 442 +++++++++++++++++ 4 files changed, 624 insertions(+), 403 deletions(-) create mode 100644 frontend/lib/temperature-chart-paths.ts diff --git a/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx b/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx index 42c2d207..cf04893e 100644 --- a/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx +++ b/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx @@ -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 实况"); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index a7ca2b6d..ea79446d 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -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", + ); } diff --git a/frontend/lib/chart-utils.ts b/frontend/lib/chart-utils.ts index f84abb5b..7af32310 100644 --- a/frontend/lib/chart-utils.ts +++ b/frontend/lib/chart-utils.ts @@ -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, -) { - 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, -) { - 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; - 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, - }; - } - - const normalizedObservations = dedupeObservationItems(observations); - const latestObservationMinute = normalizedObservations.reduce( - (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, - }; - } - // 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, - }; - } - - 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(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(); - 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 = []; - 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, diff --git a/frontend/lib/temperature-chart-paths.ts b/frontend/lib/temperature-chart-paths.ts new file mode 100644 index 00000000..db2d3f2d --- /dev/null +++ b/frontend/lib/temperature-chart-paths.ts @@ -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, +) { + 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; +} + +/** + * 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 | 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 = useMgm + ? mgmRows.map((row) => row?.temp ?? null) + : primaryTemps; + + const dataByHour = new Map(); + 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 = []; + 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, +) { + 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; + /** Past portion (solid line). */ + debPast: Array; + /** Future portion (dashed line). */ + debFuture: Array; + /** 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, + 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( + (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 = 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; +} + +/** + * 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, + 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(); + 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( + (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 { + const points = new Array(times.length).fill(null) as Array; + 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; +}