Let chart views avoid the dashboard utility bulk
The intraday chart data builder lived inside dashboard-utils with observation-source and TAF helpers, so chart consumers had to depend on the large dashboard utility surface. This moves chart data preparation, observation-source helpers, and TAF marker labels into focused modules while keeping dashboard-utils re-export compatibility. Constraint: Preserve existing chart data shape, observation labels, TAF labels, and legacy dashboard-utils exports. Rejected: Rewrite chart data generation while moving it | this pass is a boundary move only so visual behavior remains stable. Confidence: high Scope-risk: moderate Reversibility: clean Tested: TypeScript diagnostics for chart-utils, dashboard-utils, observation-source-utils, and taf-utils Tested: npm run build Not-tested: Browser visual regression across every chart city.
This commit is contained in:
@@ -12,10 +12,10 @@ import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
|
|||||||
import { CityDetail } from "@/lib/dashboard-types";
|
import { CityDetail } from "@/lib/dashboard-types";
|
||||||
import { trackAppEvent } from "@/lib/app-analytics";
|
import { trackAppEvent } from "@/lib/app-analytics";
|
||||||
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
|
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
|
||||||
|
import { getTemperatureChartData } from "@/lib/chart-utils";
|
||||||
import {
|
import {
|
||||||
getCityProfileStats,
|
getCityProfileStats,
|
||||||
getRiskBadgeLabel,
|
getRiskBadgeLabel,
|
||||||
getTemperatureChartData,
|
|
||||||
} from "@/lib/dashboard-utils";
|
} from "@/lib/dashboard-utils";
|
||||||
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
|
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ import {
|
|||||||
ModelForecast,
|
ModelForecast,
|
||||||
ProbabilityDistribution,
|
ProbabilityDistribution,
|
||||||
} from "@/components/dashboard/PanelSections";
|
} from "@/components/dashboard/PanelSections";
|
||||||
|
import { getTemperatureChartData } from "@/lib/chart-utils";
|
||||||
import {
|
import {
|
||||||
getFutureModalView,
|
getFutureModalView,
|
||||||
getModelView,
|
getModelView,
|
||||||
getProbabilityView,
|
getProbabilityView,
|
||||||
getTemperatureChartData,
|
|
||||||
getWeatherSummary,
|
getWeatherSummary,
|
||||||
} from "@/lib/dashboard-utils";
|
} from "@/lib/dashboard-utils";
|
||||||
import { getTodayPaceView } from "@/lib/pace-utils";
|
import { getTodayPaceView } from "@/lib/pace-utils";
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import {
|
|||||||
MarketTopBucket,
|
MarketTopBucket,
|
||||||
ProbabilityBucket,
|
ProbabilityBucket,
|
||||||
} from "@/lib/dashboard-types";
|
} from "@/lib/dashboard-types";
|
||||||
|
import { getTemperatureChartData } from "@/lib/chart-utils";
|
||||||
import {
|
import {
|
||||||
getHeroMetaItems,
|
getHeroMetaItems,
|
||||||
getModelView,
|
getModelView,
|
||||||
getProbabilityView,
|
getProbabilityView,
|
||||||
getRiskBadgeLabel,
|
getRiskBadgeLabel,
|
||||||
getTemperatureChartData,
|
|
||||||
getWeatherSummary,
|
getWeatherSummary,
|
||||||
} from "@/lib/dashboard-utils";
|
} from "@/lib/dashboard-utils";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { BarChart3 } from "lucide-react";
|
|||||||
import type { CityDetail } from "@/lib/dashboard-types";
|
import type { CityDetail } from "@/lib/dashboard-types";
|
||||||
import { useChart } from "@/hooks/useChart";
|
import { useChart } from "@/hooks/useChart";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
import { getTemperatureChartData } from "@/lib/dashboard-utils";
|
import { getTemperatureChartData } from "@/lib/chart-utils";
|
||||||
|
|
||||||
type TemperatureChartData = NonNullable<ReturnType<typeof getTemperatureChartData>>;
|
type TemperatureChartData = NonNullable<ReturnType<typeof getTemperatureChartData>>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,765 @@
|
|||||||
|
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 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 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),
|
||||||
|
...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 hmToMinutes = (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 = hmToMinutes(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 = 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 = hmToMinutes(normalizeHm(marker.startLocal));
|
||||||
|
return start !== null && start > currentMinutes;
|
||||||
|
}) || null
|
||||||
|
: null;
|
||||||
|
const peakWindowTafMarker =
|
||||||
|
peakWindowStartMinutes !== null && peakWindowEndMinutes !== null
|
||||||
|
? tafMarkers.find((marker) => {
|
||||||
|
const start = hmToMinutes(normalizeHm(marker.startLocal));
|
||||||
|
const end = hmToMinutes(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 (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 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: hmToMinutes(marker.labelTime) ?? 0,
|
||||||
|
y: tafMarkerValue,
|
||||||
|
}))
|
||||||
|
.filter((point) => point.x > 0);
|
||||||
|
const tafPeakWindowMarkerSeries = tafMarkers
|
||||||
|
.filter((marker) => marker.isPeakWindow && !marker.isCurrent)
|
||||||
|
.map((marker) => ({
|
||||||
|
marker,
|
||||||
|
x: hmToMinutes(marker.labelTime) ?? 0,
|
||||||
|
y: tafMarkerValue - 0.15,
|
||||||
|
}))
|
||||||
|
.filter((point) => point.x > 0);
|
||||||
|
const tafMarkerSeries = tafMarkers
|
||||||
|
.map((marker) => ({
|
||||||
|
marker,
|
||||||
|
x: hmToMinutes(marker.labelTime) ?? 0,
|
||||||
|
y: tafMarkerValue,
|
||||||
|
}))
|
||||||
|
.filter((point) => point.x > 0);
|
||||||
|
const xMin = times.length ? hmToMinutes(times[0]) ?? 0 : 0;
|
||||||
|
const xMax = times.length ? hmToMinutes(times[times.length - 1]) ?? 24 * 60 : 24 * 60;
|
||||||
|
|
||||||
|
return {
|
||||||
|
datasets: {
|
||||||
|
airportMetarPoints,
|
||||||
|
airportMetarSeries,
|
||||||
|
debFuture,
|
||||||
|
debFutureSeries,
|
||||||
|
debPast,
|
||||||
|
debPastSeries,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+11
-854
@@ -5,18 +5,23 @@ import {
|
|||||||
HistoryPoint,
|
HistoryPoint,
|
||||||
NearbyStation,
|
NearbyStation,
|
||||||
} from "@/lib/dashboard-types";
|
} from "@/lib/dashboard-types";
|
||||||
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
|
import {
|
||||||
|
getNoaaStationCode,
|
||||||
|
getNoaaStationName,
|
||||||
|
getObservationSourceCode,
|
||||||
|
getObservationSourceTag,
|
||||||
|
getRealtimeObservationTag,
|
||||||
|
isTurkishMgmCity,
|
||||||
|
} from "@/lib/observation-source-utils";
|
||||||
import {
|
import {
|
||||||
formatTemperatureValue,
|
formatTemperatureValue,
|
||||||
normalizeTemperatureLabel,
|
normalizeTemperatureLabel,
|
||||||
normalizeTemperatureSymbol,
|
normalizeTemperatureSymbol,
|
||||||
} from "@/lib/temperature-utils";
|
} from "@/lib/temperature-utils";
|
||||||
import {
|
import { formatTafMarkerType } from "@/lib/taf-utils";
|
||||||
hmToMinutes,
|
import { normalizeHm } from "@/lib/time-utils";
|
||||||
interpolateSeriesAtMinutes,
|
|
||||||
normalizeHm,
|
|
||||||
} from "@/lib/time-utils";
|
|
||||||
|
|
||||||
|
export { getTemperatureChartData } from "@/lib/chart-utils";
|
||||||
export { getTodayPaceView } from "@/lib/pace-utils";
|
export { getTodayPaceView } from "@/lib/pace-utils";
|
||||||
export {
|
export {
|
||||||
formatTemperatureValue,
|
formatTemperatureValue,
|
||||||
@@ -88,78 +93,6 @@ function getLocalizedDynamicCommentary(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTurkishMgmCity(detail: CityDetail) {
|
|
||||||
const city = String(detail.name || detail.display_name || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
return city === "ankara" || city === "istanbul";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getObservationSourceCode(detail: CityDetail): string {
|
|
||||||
const source = String(detail.current?.settlement_source || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (source) return source;
|
|
||||||
|
|
||||||
const city = String(detail.name || detail.display_name || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (
|
|
||||||
city === "hong kong" ||
|
|
||||||
city === "shek kong" ||
|
|
||||||
city === "lau fau shan"
|
|
||||||
) {
|
|
||||||
return "hko";
|
|
||||||
}
|
|
||||||
if (city === "taipei") return "noaa";
|
|
||||||
return "metar";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getObservationSourceTag(detail: CityDetail): string {
|
|
||||||
const label = normalizeObservationSourceLabel(
|
|
||||||
detail.current?.settlement_source_label,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
.trim()
|
|
||||||
.toUpperCase();
|
|
||||||
if (label) return label;
|
|
||||||
const code = getObservationSourceCode(detail);
|
|
||||||
if (code === "hko") return "HKO";
|
|
||||||
if (code === "cwa") return "CWA";
|
|
||||||
if (code === "noaa") return "NOAA";
|
|
||||||
if (code === "wunderground") {
|
|
||||||
const icao = String(detail.risk?.icao || detail.current?.station_code || "")
|
|
||||||
.trim()
|
|
||||||
.toUpperCase();
|
|
||||||
return icao ? `${icao} METAR` : "METAR";
|
|
||||||
}
|
|
||||||
if (code === "mgm") return "MGM";
|
|
||||||
return "METAR";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRealtimeObservationTag(detail: CityDetail): string {
|
|
||||||
const code = getObservationSourceCode(detail);
|
|
||||||
if (code === "wunderground") {
|
|
||||||
const icao = String(detail.risk?.icao || "").trim().toUpperCase();
|
|
||||||
return icao ? `${icao} METAR` : "METAR";
|
|
||||||
}
|
|
||||||
return getObservationSourceTag(detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNoaaStationCode(detail: CityDetail): string {
|
|
||||||
return String(detail.current?.station_code || detail.risk?.icao || "NOAA")
|
|
||||||
.trim()
|
|
||||||
.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNoaaStationName(detail: CityDetail): string {
|
|
||||||
return (
|
|
||||||
String(detail.current?.station_name || "").trim() ||
|
|
||||||
String(detail.risk?.airport || "").trim() ||
|
|
||||||
getNoaaStationCode(detail)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCloudSummary(
|
function normalizeCloudSummary(
|
||||||
cloudDesc: string | null | undefined,
|
cloudDesc: string | null | undefined,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
@@ -246,216 +179,6 @@ export function getWeatherSummary(detail: CityDetail, locale: Locale = "zh-CN")
|
|||||||
return { weatherIcon, weatherText };
|
return { weatherIcon, weatherText };
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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 getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
|
export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
|
||||||
const current = detail.current || {};
|
const current = detail.current || {};
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
@@ -523,542 +246,6 @@ export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
|
|||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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),
|
|
||||||
...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 hmToMinutes = (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 = hmToMinutes(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 = 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 = hmToMinutes(normalizeHm(marker.startLocal));
|
|
||||||
return start !== null && start > currentMinutes;
|
|
||||||
}) || null
|
|
||||||
: null;
|
|
||||||
const peakWindowTafMarker =
|
|
||||||
peakWindowStartMinutes !== null && peakWindowEndMinutes !== null
|
|
||||||
? tafMarkers.find((marker) => {
|
|
||||||
const start = hmToMinutes(normalizeHm(marker.startLocal));
|
|
||||||
const end = hmToMinutes(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 (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 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: hmToMinutes(marker.labelTime) ?? 0,
|
|
||||||
y: tafMarkerValue,
|
|
||||||
}))
|
|
||||||
.filter((point) => point.x > 0);
|
|
||||||
const tafPeakWindowMarkerSeries = tafMarkers
|
|
||||||
.filter((marker) => marker.isPeakWindow && !marker.isCurrent)
|
|
||||||
.map((marker) => ({
|
|
||||||
marker,
|
|
||||||
x: hmToMinutes(marker.labelTime) ?? 0,
|
|
||||||
y: tafMarkerValue - 0.15,
|
|
||||||
}))
|
|
||||||
.filter((point) => point.x > 0);
|
|
||||||
const tafMarkerSeries = tafMarkers
|
|
||||||
.map((marker) => ({
|
|
||||||
marker,
|
|
||||||
x: hmToMinutes(marker.labelTime) ?? 0,
|
|
||||||
y: tafMarkerValue,
|
|
||||||
}))
|
|
||||||
.filter((point) => point.x > 0);
|
|
||||||
const xMin = times.length ? hmToMinutes(times[0]) ?? 0 : 0;
|
|
||||||
const xMax = times.length ? hmToMinutes(times[times.length - 1]) ?? 24 * 60 : 24 * 60;
|
|
||||||
|
|
||||||
return {
|
|
||||||
datasets: {
|
|
||||||
airportMetarPoints,
|
|
||||||
airportMetarSeries,
|
|
||||||
debFuture,
|
|
||||||
debFutureSeries,
|
|
||||||
debPast,
|
|
||||||
debPastSeries,
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProbabilityView(detail: CityDetail, targetDate?: string | null) {
|
export function getProbabilityView(detail: CityDetail, targetDate?: string | null) {
|
||||||
const date = targetDate || detail.local_date;
|
const date = targetDate || detail.local_date;
|
||||||
if (date === detail.local_date) {
|
if (date === detail.local_date) {
|
||||||
@@ -1373,36 +560,6 @@ function bucketLabel(bucket: string | null, locale: Locale = "zh-CN") {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTafMarkerType(type: string, locale: Locale = "zh-CN") {
|
|
||||||
const normalized = String(type || "").trim().toUpperCase();
|
|
||||||
if (isEnglish(locale)) {
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
BASE: "Base regime",
|
|
||||||
FM: "Hard shift",
|
|
||||||
TEMPO: "Temporary swing",
|
|
||||||
BECMG: "Gradual shift",
|
|
||||||
PROB30: "30% risk window",
|
|
||||||
PROB40: "40% risk window",
|
|
||||||
"PROB30 TEMPO": "30% temporary swing",
|
|
||||||
"PROB40 TEMPO": "40% temporary swing",
|
|
||||||
}[normalized] || normalized
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
BASE: "基础时段",
|
|
||||||
FM: "明确切换",
|
|
||||||
TEMPO: "临时波动",
|
|
||||||
BECMG: "逐步转变",
|
|
||||||
PROB30: "30% 风险窗",
|
|
||||||
PROB40: "40% 风险窗",
|
|
||||||
"PROB30 TEMPO": "30% 临时波动",
|
|
||||||
"PROB40 TEMPO": "40% 临时波动",
|
|
||||||
}[normalized] || normalized
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function wuRound(value: number | null | undefined) {
|
export function wuRound(value: number | null | undefined) {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
if (!Number.isFinite(numeric)) return null;
|
if (!Number.isFinite(numeric)) return null;
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { CityDetail } from "@/lib/dashboard-types";
|
||||||
|
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
|
||||||
|
|
||||||
|
export function isTurkishMgmCity(detail: CityDetail) {
|
||||||
|
const city = String(detail.name || detail.display_name || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
return city === "ankara" || city === "istanbul";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObservationSourceCode(detail: CityDetail): string {
|
||||||
|
const source = String(detail.current?.settlement_source || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (source) return source;
|
||||||
|
|
||||||
|
const city = String(detail.name || detail.display_name || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (
|
||||||
|
city === "hong kong" ||
|
||||||
|
city === "shek kong" ||
|
||||||
|
city === "lau fau shan"
|
||||||
|
) {
|
||||||
|
return "hko";
|
||||||
|
}
|
||||||
|
if (city === "taipei") return "noaa";
|
||||||
|
return "metar";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObservationSourceTag(detail: CityDetail): string {
|
||||||
|
const label = normalizeObservationSourceLabel(
|
||||||
|
detail.current?.settlement_source_label,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
|
if (label) return label;
|
||||||
|
const code = getObservationSourceCode(detail);
|
||||||
|
if (code === "hko") return "HKO";
|
||||||
|
if (code === "cwa") return "CWA";
|
||||||
|
if (code === "noaa") return "NOAA";
|
||||||
|
if (code === "wunderground") {
|
||||||
|
const icao = String(detail.risk?.icao || detail.current?.station_code || "")
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
|
return icao ? `${icao} METAR` : "METAR";
|
||||||
|
}
|
||||||
|
if (code === "mgm") return "MGM";
|
||||||
|
return "METAR";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRealtimeObservationTag(detail: CityDetail): string {
|
||||||
|
const code = getObservationSourceCode(detail);
|
||||||
|
if (code === "wunderground") {
|
||||||
|
const icao = String(detail.risk?.icao || "").trim().toUpperCase();
|
||||||
|
return icao ? `${icao} METAR` : "METAR";
|
||||||
|
}
|
||||||
|
return getObservationSourceTag(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNoaaStationCode(detail: CityDetail): string {
|
||||||
|
return String(detail.current?.station_code || detail.risk?.icao || "NOAA")
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNoaaStationName(detail: CityDetail): string {
|
||||||
|
return (
|
||||||
|
String(detail.current?.station_name || "").trim() ||
|
||||||
|
String(detail.risk?.airport || "").trim() ||
|
||||||
|
getNoaaStationCode(detail)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Locale } from "@/lib/i18n";
|
||||||
|
|
||||||
|
function isEnglish(locale: Locale) {
|
||||||
|
return locale === "en-US";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTafMarkerType(type: string, locale: Locale = "zh-CN") {
|
||||||
|
const normalized = String(type || "").trim().toUpperCase();
|
||||||
|
if (isEnglish(locale)) {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
BASE: "Base regime",
|
||||||
|
FM: "Hard shift",
|
||||||
|
TEMPO: "Temporary swing",
|
||||||
|
BECMG: "Gradual shift",
|
||||||
|
PROB30: "30% risk window",
|
||||||
|
PROB40: "40% risk window",
|
||||||
|
"PROB30 TEMPO": "30% temporary swing",
|
||||||
|
"PROB40 TEMPO": "40% temporary swing",
|
||||||
|
}[normalized] || normalized
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
BASE: "基础时段",
|
||||||
|
FM: "明确切换",
|
||||||
|
TEMPO: "临时波动",
|
||||||
|
BECMG: "逐步转变",
|
||||||
|
PROB30: "30% 风险窗",
|
||||||
|
PROB40: "40% 风险窗",
|
||||||
|
"PROB30 TEMPO": "30% 临时波动",
|
||||||
|
"PROB40 TEMPO": "40% 临时波动",
|
||||||
|
}[normalized] || normalized
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user