Files
PolyWeather/frontend/lib/chart-utils.ts
T

903 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { CityDetail } from "@/lib/dashboard-types";
import type { Locale } from "@/lib/i18n";
import {
getNoaaStationCode,
getObservationSourceCode,
getObservationSourceTag,
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";
function isEnglish(locale: Locale) {
return locale === "en-US";
}
function findNearestTimeIndex(
times: string[],
targetTime?: string | null,
) {
const targetMinutes = hmToMinutes(targetTime);
if (targetMinutes == null || !times.length) return -1;
let nearestIndex = -1;
let nearestDelta = Number.POSITIVE_INFINITY;
times.forEach((time, index) => {
const minute = hmToMinutes(time);
if (minute == null) return;
const delta = Math.abs(minute - targetMinutes);
if (delta < nearestDelta) {
nearestDelta = delta;
nearestIndex = index;
}
});
return nearestIndex;
}
function buildTemperatureTickLabels(times: string[]) {
const lastIndex = Math.max(0, times.length - 1);
return times.map((time, index) => {
if (index === 0 || index === lastIndex) return time;
const minute = hmToMinutes(time);
if (minute == null) return "";
const hour = Math.floor(minute / 60);
const minutePart = minute % 60;
if (minutePart !== 0) return "";
return hour % 2 === 0 ? time : "";
});
}
function getNiceTemperatureScale(values: number[], tempSymbol?: string | null) {
const numericValues = values.filter((value) => Number.isFinite(Number(value)));
if (!numericValues.length) {
return { max: 1, min: 0, step: 1 };
}
const rawMin = Math.min(...numericValues);
const rawMax = Math.max(...numericValues);
const spread = Math.max(0.1, rawMax - rawMin);
const isFahrenheit = normalizeTemperatureSymbol(tempSymbol) === "°F";
const padding = Math.max(isFahrenheit ? 1.5 : 0.8, spread * 0.12);
const paddedMin = rawMin - padding;
const paddedMax = rawMax + padding;
const paddedSpread = Math.max(0.1, paddedMax - paddedMin);
const candidates = isFahrenheit ? [1, 2, 5, 10, 20] : [0.5, 1, 2, 5, 10];
let step =
candidates.find((candidate) => candidate >= paddedSpread / 4) ||
candidates[candidates.length - 1];
let min = Math.floor(paddedMin / step) * step;
let max = Math.ceil(paddedMax / step) * step;
if (min < 0 && rawMin >= 0 && rawMin <= step * 1.25) min = 0;
if (max <= min) max = min + step * 4;
while ((max - min) / step + 1 > 6) {
const nextStep = candidates.find((candidate) => candidate > step);
if (!nextStep) break;
step = nextStep;
min = Math.floor(paddedMin / step) * step;
max = Math.ceil(paddedMax / step) * step;
if (min < 0 && rawMin >= 0 && rawMin <= step * 1.25) min = 0;
}
return { max, min, step };
}
function buildSeriesPoints(
times: string[],
values: Array<number | null | undefined>,
) {
return times
.map((time, index) => {
const x = hmToMinutes(time);
const y = values[index];
return x != null && y != null && Number.isFinite(Number(y))
? { index, labelTime: time, x, y: Number(y) }
: null;
})
.filter(
(point): point is { index: number; labelTime: string; x: number; y: number } =>
point != null,
);
}
function buildObservationPoints(items: Array<{ time?: string; temp?: number | null }>) {
return items
.map((item) => {
const labelTime = normalizeHm(String(item.time || ""));
const x = hmToMinutes(labelTime);
const y = item.temp;
return x != null && y != null && Number.isFinite(Number(y))
? { labelTime: labelTime || "", x, y: Number(y) }
: null;
})
.filter((point): point is { labelTime: string; x: number; y: number } => point != null);
}
function 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 (currentMinutes == null || !times.length || !observations.length) {
return {
adjustmentDelta: null as number | null,
future: new Array(times.length).fill(null) as Array<number | null>,
};
}
const deltas = dedupeObservationItems(observations)
.map((item) => {
const minute = hmToMinutes(item.time);
const observed = Number(item.temp);
if (
minute == null ||
minute > currentMinutes + 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 > currentMinutes
? reversionMinutes
: lastSeriesMinute != null && lastSeriesMinute > currentMinutes
? lastSeriesMinute
: currentMinutes + 6 * 60;
const future = times.map((time, index) => {
const minute = hmToMinutes(time);
const base = debTemps[index];
if (
minute == null ||
minute < currentMinutes ||
base == null ||
!Number.isFinite(base)
) {
return null;
}
const progressToEvening = Math.min(
Math.max(
(minute - currentMinutes) /
Math.max(returnToBaselineMinute - currentMinutes, 1),
0,
),
1,
);
// Strongest right after the latest observation, then smoothly fades back
// to the unchanged DEB baseline by evening/sunset. This keeps one METAR
// point from dragging the whole-day forecast away from the base path.
const decay = Math.pow(1 - progressToEvening, 1.35);
return Number((base + adjustmentDelta * decay).toFixed(1));
});
return { adjustmentDelta, future };
}
function sortObservationItemsByTime<T extends { time?: string | null }>(items: T[]) {
return [...items].sort((left, right) => {
const leftMinutes = hmToMinutes(left.time);
const rightMinutes = hmToMinutes(right.time);
if (leftMinutes == null && rightMinutes == null) return 0;
if (leftMinutes == null) return 1;
if (rightMinutes == null) return -1;
return leftMinutes - rightMinutes;
});
}
function dedupeObservationItems<T extends { temp?: number | null; time?: string | null }>(
items: T[],
) {
const byTime = new Map<string, T>();
items.forEach((item) => {
const time = normalizeHm(item.time);
const value = Number(item.temp);
if (!time || !Number.isFinite(value)) return;
const existing = byTime.get(time);
if (!existing || Number(item.temp) >= Number(existing.temp)) {
byTime.set(time, { ...item, time });
}
});
return sortObservationItemsByTime([...byTime.values()]);
}
function looksLikeForecastMirror(
observations: Array<{ temp?: number | null; time?: string | null }>,
forecastTimes: string[],
forecastValues: Array<number | null | undefined>,
) {
const unique = dedupeObservationItems(observations);
if (unique.length < 6 || forecastTimes.length < 6) return false;
if (unique.length < Math.max(6, Math.floor(forecastTimes.length * 0.4))) {
return false;
}
let compared = 0;
let exactMatches = 0;
unique.forEach((item) => {
const minute = hmToMinutes(item.time);
const observed = Number(item.temp);
if (minute == null || !Number.isFinite(observed)) return;
const expected = interpolateSeriesAtMinutes(
forecastTimes,
forecastValues,
minute,
);
if (expected == null || !Number.isFinite(expected)) return;
compared += 1;
if (Math.abs(observed - expected) <= 0.05) {
exactMatches += 1;
}
});
return compared >= 6 && exactMatches / compared >= 0.65;
}
function normalizeObservationTimeForChart(
value: unknown,
detail: CityDetail,
) {
const raw = String(value || "").trim();
if (raw && !raw.includes("T")) {
return normalizeHm(raw) || raw;
}
return normalizeHm(detail.local_time) || normalizeHm(raw) || raw;
}
function buildCurrentObservationFallback(
detail: CityDetail,
): Array<{ time?: string; temp?: number | null; sourceLabel?: string | null }> {
const candidates: Array<{
sourceLabel?: string | null;
temp?: number | null;
time?: string | null;
}> = [
{
sourceLabel: detail.current?.settlement_source_label,
temp: detail.current?.temp,
time: detail.current?.obs_time || detail.current?.report_time,
},
{
sourceLabel: detail.airport_primary?.source_label || "METAR",
temp: detail.airport_primary?.temp,
time: detail.airport_primary?.obs_time || detail.airport_primary?.report_time,
},
{
sourceLabel: detail.airport_current?.source_label || "METAR",
temp: detail.airport_current?.temp,
time: detail.airport_current?.obs_time || detail.airport_current?.report_time,
},
];
const first = candidates.find((item) => {
const numeric = Number(item.temp);
return Number.isFinite(numeric);
});
if (!first) return [];
return [
{
sourceLabel: first.sourceLabel,
temp: Number(first.temp),
time: normalizeObservationTimeForChart(first.time, detail),
},
];
}
export function getTemperatureChartData(
detail: CityDetail,
locale: Locale = "zh-CN",
) {
const hourly = detail.hourly || {};
const rawTimes = Array.isArray(hourly.times) ? hourly.times : [];
const rawTemps = Array.isArray(hourly.temps) ? hourly.temps : [];
const times = rawTimes
.map((time) => String(time || "").trim())
.filter(Boolean);
const temps = times.map((_, index) => {
const value = Number(rawTemps[index]);
return Number.isFinite(value) ? value : null;
});
const suppressAnkaraMgmObservation = isTurkishMgmCity(detail);
if (!times.length) return null;
const currentIndex = findNearestTimeIndex(times, detail.local_time);
const omMax = detail.forecast?.today_high;
const debMax = detail.deb?.prediction;
const offset =
debMax != null && omMax != null ? Number(debMax) - Number(omMax) : 0;
const debTemps = temps.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 observationTag = getRealtimeObservationTag(detail);
const observationCode = getObservationSourceCode(detail);
const settlementSource =
observationCode === "hko" ||
observationCode === "cwa" ||
observationCode === "noaa" ||
observationCode === "wunderground";
const useSettlementObservationSource = settlementSource;
const officialObservationSource =
useSettlementObservationSource
? detail.settlement_today_obs?.length
? detail.settlement_today_obs
: detail.current?.obs_time && detail.current?.temp != null
? [{ time: detail.current.obs_time, temp: detail.current.temp }]
: []
: [];
const currentObservationFallback = buildCurrentObservationFallback(detail);
const minPlausibleObservationTemp = (() => {
const name = String(detail.name || "").trim().toLowerCase();
const icao = String(detail.risk?.icao || "").trim().toUpperCase();
if (name === "karachi" || name === "masroor air base" || icao === "OPKC" || icao === "OPMR") {
return detail.temp_symbol === "°F" ? 41 : 5;
}
return null;
})();
const filterPlausibleObservations = <T extends { temp?: number | null }>(
rows?: T[] | null,
) =>
(Array.isArray(rows) ? rows : []).filter((row) => {
const value = Number(row?.temp);
if (!Number.isFinite(value)) return false;
return minPlausibleObservationTemp == null || value >= minPlausibleObservationTemp;
});
const plausibleMetarTodayObs = filterPlausibleObservations(detail.metar_today_obs);
const plausibleTrendRecent = filterPlausibleObservations(detail.trend?.recent);
const plausibleCurrentFallback = filterPlausibleObservations(currentObservationFallback);
const metarObservationSource = plausibleMetarTodayObs.length
? plausibleMetarTodayObs
: plausibleTrendRecent.length
? plausibleTrendRecent
: plausibleCurrentFallback;
const usingCurrentObservationFallback =
!plausibleMetarTodayObs.length &&
!plausibleTrendRecent.length &&
plausibleCurrentFallback.length > 0;
const currentFallbackTag =
currentObservationFallback[0]?.sourceLabel ||
getObservationSourceTag(detail);
const allowMetarFallback = settlementSource && observationCode !== "hko";
const shouldUseMetarFallback =
allowMetarFallback &&
officialObservationSource.length > 0 &&
officialObservationSource.length < 3 &&
metarObservationSource.length >= 3;
let usingMetarObservationSource =
!useSettlementObservationSource || shouldUseMetarFallback;
let observationSource = useSettlementObservationSource
? shouldUseMetarFallback
? metarObservationSource
: officialObservationSource
: metarObservationSource;
let usingMirrorFallback = false;
if (looksLikeForecastMirror(observationSource, times, debTemps)) {
const fallbackCandidates = [
plausibleTrendRecent,
plausibleCurrentFallback,
metarObservationSource,
];
const fallback = fallbackCandidates.find(
(candidate) =>
candidate.length > 0 &&
candidate !== observationSource &&
!looksLikeForecastMirror(candidate, times, debTemps),
);
if (fallback) {
observationSource = fallback;
usingMetarObservationSource = fallback === metarObservationSource;
usingMirrorFallback = true;
}
}
observationSource = dedupeObservationItems(observationSource);
const airportMetarSource: Array<{ time?: string; temp?: number | null }> = [];
const metarFallbackTag = (() => {
const icao = String(detail.risk?.icao || "").trim().toUpperCase();
if (!icao) return "METAR";
return `${icao} METAR`;
})();
const observationDisplayTag =
usingCurrentObservationFallback
? String(currentFallbackTag).toUpperCase()
: observationCode === "wunderground" && usingMetarObservationSource
? metarFallbackTag
: observationCode === "wunderground"
? metarFallbackTag
: useSettlementObservationSource && shouldUseMetarFallback
? metarFallbackTag
: observationCode === "noaa"
? `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 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,
times,
debTemps,
currentMinutes: hmToMinutes(detail.local_time),
reversionMinutes:
hmToMinutes(detail.forecast?.sunset) ?? hmToMinutes("18:00"),
});
const calibratedFuture = calibratedPath.future;
const mgmPoints = new Array(times.length).fill(null);
if (
!suppressAnkaraMgmObservation &&
detail.mgm?.temp != null &&
detail.mgm?.time
) {
const index = findNearestTimeIndex(times, detail.mgm.time);
const temp = Number(detail.mgm.temp);
if (index >= 0 && Number.isFinite(temp)) {
mgmPoints[index] = temp;
}
}
const mgmHourlyPoints = new Array(times.length).fill(null);
let hasMgmHourly = false;
const mgmHourlyRows = Array.isArray(detail.mgm?.hourly)
? detail.mgm?.hourly || []
: [];
mgmHourlyRows.forEach((item) => {
const index = findNearestTimeIndex(times, String(item.time || ""));
const temp = Number(item.temp);
if (index >= 0 && Number.isFinite(temp)) {
mgmHourlyPoints[index] = temp;
hasMgmHourly = true;
}
});
const allValues = [
...debTemps.filter((value) => value != null),
...calibratedFuture.filter((value) => value != null),
...metarPoints.filter((value) => value != null),
...airportMetarPoints.filter((value) => value != null),
...mgmPoints.filter((value) => value != null),
...mgmHourlyPoints.filter((value) => value != null),
] as number[];
if (!allValues.length) return null;
const yScale = getNiceTemperatureScale(allValues, detail.temp_symbol);
const min = yScale.min;
const max = yScale.max;
const yTickStep = yScale.step;
const tafMarkersRaw = Array.isArray(detail.taf?.signal?.markers)
? detail.taf?.signal?.markers || []
: [];
const normalizeHm = (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(normalizeHm(detail.local_time));
const peakFirstHour = Number(detail.peak?.first_h);
const peakLastHour = Number(detail.peak?.last_h);
const peakWindowStartMinutes =
Number.isFinite(peakFirstHour) && peakFirstHour >= 0
? Math.max(0, (peakFirstHour - 2) * 60)
: null;
const peakWindowEndMinutes =
Number.isFinite(peakLastHour) && peakLastHour >= peakFirstHour
? Math.min(23 * 60 + 59, (peakLastHour + 1) * 60)
: null;
const tafMarkerValue = max - 0.4;
const tafMarkerPoints = new Array(times.length).fill(null);
const tafCurrentMarkerPoints = new Array(times.length).fill(null);
const tafPeakWindowMarkerPoints = new Array(times.length).fill(null);
const sameMarker = (
left:
| { markerType?: string | null; startLocal?: string | null; endLocal?: string | null }
| null
| undefined,
right:
| { markerType?: string | null; startLocal?: string | null; endLocal?: string | null }
| null
| undefined,
) =>
!!left &&
!!right &&
String(left.markerType || "") === String(right.markerType || "") &&
String(left.startLocal || "") === String(right.startLocal || "") &&
String(left.endLocal || "") === String(right.endLocal || "");
const tafMarkers = tafMarkersRaw
.map((marker) => {
const labelTime = String(marker?.label_time || "").trim();
const index = findNearestTimeIndex(times, labelTime);
if (index >= 0) {
tafMarkerPoints[index] = tafMarkerValue;
}
return {
displayType: formatTafMarkerType(
String(marker?.marker_type || "").trim(),
locale,
),
endLocal: String(marker?.end_local || "").trim(),
index,
labelTime,
markerType: String(marker?.marker_type || "").trim(),
startLocal: String(marker?.start_local || "").trim(),
summary:
isEnglish(locale)
? String(marker?.summary_en || "").trim()
: String(marker?.summary_zh || "").trim(),
isCurrent: false,
isPeakWindow: false,
suppressionLevel: String(marker?.suppression_level || "").trim(),
};
})
.filter((marker) => marker.index >= 0);
const currentTafMarker =
currentMinutes !== null
? tafMarkers.find((marker) => {
const start = chartHmToMinutes(normalizeHm(marker.startLocal));
const end = chartHmToMinutes(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(normalizeHm(marker.startLocal));
return start !== null && start > currentMinutes;
}) || null
: null;
const peakWindowTafMarker =
peakWindowStartMinutes !== null && peakWindowEndMinutes !== null
? tafMarkers.find((marker) => {
const start = chartHmToMinutes(normalizeHm(marker.startLocal));
const end = chartHmToMinutes(normalizeHm(marker.endLocal));
return (
start !== null &&
end !== null &&
start <= peakWindowEndMinutes &&
end >= peakWindowStartMinutes
);
}) || null
: null;
tafMarkers.forEach((marker) => {
const isPrimaryTafMarker =
sameMarker(marker, currentTafMarker) || sameMarker(marker, nextTafMarker);
const isPeakReferenceMarker = sameMarker(marker, peakWindowTafMarker);
if (isPrimaryTafMarker) {
marker.isCurrent = true;
tafCurrentMarkerPoints[marker.index] = tafMarkerValue;
}
if (isPeakReferenceMarker) {
marker.isPeakWindow = true;
if (!isPrimaryTafMarker) {
tafPeakWindowMarkerPoints[marker.index] = tafMarkerValue - 0.15;
}
}
});
const formatTafLegendMarker = (
marker:
| { displayType?: string | null; startLocal?: string | null; endLocal?: string | null; summary?: string | null }
| null
| undefined,
) => {
if (!marker) return "";
const range = `${marker.startLocal || "--:--"}-${marker.endLocal || "--:--"}`;
const status = String(marker.summary || "").trim();
return status
? `${marker.displayType || ""} ${range} ${status}`.trim()
: `${marker.displayType || ""} ${range}`.trim();
};
const legendParts: string[] = [];
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) {
const sign = offset > 0 ? "+" : "";
legendParts.push(
isEnglish(locale)
? `DEB offset ${sign}${offset.toFixed(1)}${detail.temp_symbol} vs OM`
: `DEB 偏移 ${sign}${offset.toFixed(1)}${detail.temp_symbol} vs OM`,
);
}
if (calibratedPath.adjustmentDelta != null) {
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}。`,
);
}
if (hasMgmHourly) {
legendParts.push(
isEnglish(locale)
? "Using MGM hourly forecast to replace DEB curve"
: "已使用 MGM 小时预报替代 DEB 曲线",
);
}
if ((detail.trend?.recent?.length || 0) > 0 || observationSource.length > 0) {
const recentData = sortObservationItemsByTime(
observationSource.length > 0
? [...observationSource]
: [...(detail.trend?.recent || [])],
);
const recentText = recentData
.slice(-4)
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
.join(" -> ");
legendParts.push(`${observationDisplayTag}: ${recentText}`);
}
if (airportMetarSource.length > 0) {
const airportRecentText = sortObservationItemsByTime([...airportMetarSource])
.slice(-4)
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
.join(" -> ");
legendParts.push(
isEnglish(locale)
? `${metarFallbackTag}: ${airportRecentText}`
: `${metarFallbackTag}: ${airportRecentText}`,
);
}
if (detail.metar_status?.stale_for_today) {
const dateText = detail.metar_status.last_observation_local_date || "";
const tempText =
detail.metar_status.last_temp != null
? `${detail.metar_status.last_temp}${detail.temp_symbol}`
: "";
legendParts.push(
isEnglish(locale)
? `No same-day ${metarFallbackTag} report yet; latest report${dateText ? ` was ${dateText}` : ""}${tempText ? ` at ${tempText}` : ""}.`
: `今日暂无同日 ${metarFallbackTag} 报文;最近一报${dateText ? `为 ${dateText}` : ""}${tempText ? `${tempText}` : ""}。`,
);
}
if (shouldUseMetarFallback) {
legendParts.push(
isEnglish(locale)
? `Official ${observationTag} feed is sparse today, so the continuous observation line switches to ${metarFallbackTag}.`
: `今日官方 ${observationTag} 点位较稀疏,连续实测线改用 ${metarFallbackTag}。`,
);
}
if (usingMirrorFallback) {
legendParts.push(
isEnglish(locale)
? "Dense observation feed matched the forecast curve exactly, so it was ignored for this chart refresh."
: "本次高密度观测源与预测曲线逐点重合,已忽略该异常源。",
);
} else if (observationCode === "hko") {
legendParts.push(
isEnglish(locale)
? "This city uses HKO official readings. The chart keeps official HKO points instead of switching to airport METAR."
: "该城市按 HKO 官方读数展示;图中保留 HKO 官方点位,不切换到机场 METAR 连续线。",
);
} else if (observationCode === "noaa") {
const noaaCode = getNoaaStationCode(detail);
legendParts.push(
isEnglish(locale)
? `This city settles on NOAA ${noaaCode} using the finalized highest rounded whole-degree Celsius Temp reading; the plotted line is a settlement reference.`
: `该城市按 NOAA ${noaaCode} 最终完成质控后的最高整度摄氏 Temp 读数结算;图中曲线仅作为结算参考线。`,
);
}
if (tafMarkers.length) {
const primaryTafMarker = currentTafMarker || nextTafMarker;
if (primaryTafMarker) {
legendParts.push(
isEnglish(locale)
? `Current TAF: ${formatTafLegendMarker(primaryTafMarker)}`
: `当前 TAF${formatTafLegendMarker(primaryTafMarker)}`,
);
}
if (peakWindowTafMarker && !sameMarker(peakWindowTafMarker, primaryTafMarker)) {
legendParts.push(
isEnglish(locale)
? `Peak-window TAF: ${formatTafLegendMarker(peakWindowTafMarker)}`
: `峰值窗口 TAF${formatTafLegendMarker(peakWindowTafMarker)}`,
);
}
legendParts.push(
isEnglish(locale)
? "Use the current TAF segment as primary; peak-window segments are reference only."
: "以当前 TAF 时段为准,峰值窗口时段仅作参考。",
);
}
const debPastSeries = buildSeriesPoints(times, debPast);
const debFutureSeries = buildSeriesPoints(times, debFuture);
const debSeries = buildSeriesPoints(times, debTemps);
const calibratedFutureSeries = buildSeriesPoints(times, calibratedFuture);
const tempsSeries = buildSeriesPoints(times, temps);
const mgmHourlySeries = buildSeriesPoints(times, mgmHourlyPoints);
const metarSeries = buildObservationPoints(observationSource);
const airportMetarSeries = buildObservationPoints(airportMetarSource);
const mgmSeries =
!suppressAnkaraMgmObservation && detail.mgm?.temp != null && detail.mgm?.time
? buildObservationPoints([{ time: detail.mgm.time, temp: detail.mgm.temp }])
: [];
const tafCurrentMarkerSeries = tafMarkers
.filter((marker) => marker.isCurrent)
.map((marker) => ({
marker,
x: chartHmToMinutes(marker.labelTime) ?? 0,
y: tafMarkerValue,
}))
.filter((point) => point.x > 0);
const tafPeakWindowMarkerSeries = tafMarkers
.filter((marker) => marker.isPeakWindow && !marker.isCurrent)
.map((marker) => ({
marker,
x: chartHmToMinutes(marker.labelTime) ?? 0,
y: tafMarkerValue - 0.15,
}))
.filter((point) => point.x > 0);
const tafMarkerSeries = tafMarkers
.map((marker) => ({
marker,
x: chartHmToMinutes(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;
return {
datasets: {
airportMetarPoints,
airportMetarSeries,
calibratedFuture,
calibratedFutureSeries,
calibrationAdjustmentDelta: calibratedPath.adjustmentDelta,
debFuture,
debFutureSeries,
debPast,
debPastSeries,
debSeries,
hasMgmHourly,
metarPoints,
metarSeries,
mgmHourlyPoints,
mgmHourlySeries,
mgmPoints,
mgmSeries,
offset,
tafCurrentMarkerPoints,
tafCurrentMarkerSeries,
tafMarkerPoints,
tafMarkerSeries,
tafPeakWindowMarkerPoints,
tafPeakWindowMarkerSeries,
temps,
tempsSeries,
},
observationLabel:
observationCode === "noaa" &&
!shouldUseMetarFallback
? isEnglish(locale)
? `${observationDisplayTag} Settlement Reference`
: `${observationDisplayTag} 结算参考`
: isEnglish(locale)
? `${observationDisplayTag} Observation`
: `${observationDisplayTag} 实况`,
legendText: legendParts.join(" | "),
max,
min,
tafMarkers,
tickLabels: buildTemperatureTickLabels(times),
times,
xMax,
xMin,
yTickStep,
};
}