3533 lines
125 KiB
TypeScript
3533 lines
125 KiB
TypeScript
import { Locale } from "@/lib/i18n";
|
||
import {
|
||
AiAnalysisStructured,
|
||
CityDetail,
|
||
HistoryPoint,
|
||
NearbyStation,
|
||
} from "@/lib/dashboard-types";
|
||
|
||
const METAR_WX_MAP: Record<
|
||
string,
|
||
{ en: string; icon: string; zh: string }
|
||
> = {
|
||
VCSH: { en: "Showers nearby", icon: "🌦️", zh: "附近有阵雨" },
|
||
SHRA: { en: "Rain showers", icon: "🌦️", zh: "阵雨" },
|
||
"-SHRA": { en: "Light rain showers", icon: "🌦️", zh: "小阵雨" },
|
||
"+SHRA": { en: "Heavy rain showers", icon: "⛈️", zh: "强阵雨" },
|
||
VCRA: { en: "Rain nearby", icon: "🌧️", zh: "附近有降雨" },
|
||
TSRA: { en: "Thunderstorms with rain", icon: "⛈️", zh: "雷雨" },
|
||
"-TSRA": { en: "Light thunderstorms with rain", icon: "⛈️", zh: "小雷雨" },
|
||
"+TSRA": { en: "Heavy thunderstorms with rain", icon: "⛈️", zh: "强雷雨" },
|
||
RA: { en: "Rain", icon: "🌧️", zh: "降雨" },
|
||
"-RA": { en: "Light rain", icon: "🌦️", zh: "小雨" },
|
||
"+RA": { en: "Heavy rain", icon: "⛈️", zh: "强降雨" },
|
||
SN: { en: "Snow", icon: "❄️", zh: "降雪" },
|
||
"-SN": { en: "Light snow", icon: "🌨️", zh: "小雪" },
|
||
"+SN": { en: "Heavy snow", icon: "🌨️", zh: "大雪" },
|
||
DZ: { en: "Drizzle", icon: "🌦️", zh: "毛毛雨" },
|
||
FG: { en: "Fog", icon: "🌫️", zh: "雾" },
|
||
VCFG: { en: "Fog nearby", icon: "🌫️", zh: "附近有雾" },
|
||
MIFG: { en: "Shallow fog", icon: "🌫️", zh: "浅雾" },
|
||
BR: { en: "Mist", icon: "🌫️", zh: "薄雾" },
|
||
HZ: { en: "Haze", icon: "🌫️", zh: "霾" },
|
||
TS: { en: "Thunderstorm", icon: "⛈️", zh: "雷暴" },
|
||
VCTS: { en: "Nearby thunderstorm", icon: "⛈️", zh: "附近雷暴" },
|
||
SQ: { en: "Squall", icon: "💨", zh: "飑线" },
|
||
GS: { en: "Hail", icon: "🌨️", zh: "冰雹" },
|
||
};
|
||
|
||
function isEnglish(locale: Locale) {
|
||
return locale === "en-US";
|
||
}
|
||
|
||
function containsCjk(text: string) {
|
||
return /[\u3400-\u9fff]/.test(text);
|
||
}
|
||
|
||
function getLocalizedDynamicCommentary(
|
||
detail: CityDetail,
|
||
locale: Locale,
|
||
): { headline: string; bullets: string[]; source: string } {
|
||
const commentary = detail.dynamic_commentary || {};
|
||
const preferEnglish = isEnglish(locale);
|
||
const rawHeadline = preferEnglish
|
||
? String(commentary.headline_en || "").trim()
|
||
: String(commentary.headline_zh || "").trim();
|
||
const rawBullets = preferEnglish
|
||
? commentary.bullets_en
|
||
: commentary.bullets_zh;
|
||
const bullets = Array.isArray(rawBullets)
|
||
? rawBullets.map((item) => String(item || "").trim()).filter(Boolean)
|
||
: [];
|
||
const fallbackHeadline = String(commentary.summary || "").trim();
|
||
const fallbackBullets = Array.isArray(commentary.notes)
|
||
? commentary.notes.map((item) => String(item || "").trim()).filter(Boolean)
|
||
: [];
|
||
return {
|
||
headline: rawHeadline || fallbackHeadline,
|
||
bullets: bullets.length > 0 ? bullets : fallbackBullets,
|
||
source: String(commentary.source || "").trim(),
|
||
};
|
||
}
|
||
|
||
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 = String(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(
|
||
cloudDesc: string | null | undefined,
|
||
locale: Locale,
|
||
): { icon: string; text: string } {
|
||
const raw = String(cloudDesc || "").trim();
|
||
if (!raw) {
|
||
return { icon: "🔍", text: isEnglish(locale) ? "Unknown" : "未知" };
|
||
}
|
||
|
||
const lower = raw.toLowerCase();
|
||
if (
|
||
raw.includes("晴") ||
|
||
raw.includes("晴朗") ||
|
||
lower.includes("clear") ||
|
||
lower.includes("sunny")
|
||
) {
|
||
return { icon: "☀️", text: isEnglish(locale) ? "Clear" : "晴朗" };
|
||
}
|
||
if (raw.includes("阴") || lower.includes("overcast")) {
|
||
return { icon: "☁️", text: isEnglish(locale) ? "Overcast" : "阴天" };
|
||
}
|
||
if (raw.includes("多云") || lower.includes("cloud")) {
|
||
return { icon: "☁️", text: isEnglish(locale) ? "Cloudy" : "多云" };
|
||
}
|
||
if (raw.includes("少云") || lower.includes("few")) {
|
||
return { icon: "🌤️", text: isEnglish(locale) ? "Mostly clear" : "少云" };
|
||
}
|
||
if (raw.includes("散云") || lower.includes("scattered")) {
|
||
return { icon: "⛅", text: isEnglish(locale) ? "Partly cloudy" : "散云" };
|
||
}
|
||
return { icon: "🔍", text: raw };
|
||
}
|
||
|
||
export function translateMetar(code?: string | null, locale: Locale = "zh-CN") {
|
||
if (!code) return null;
|
||
const metarCode = String(code);
|
||
for (const [key, value] of Object.entries(METAR_WX_MAP)) {
|
||
if (metarCode.includes(key)) {
|
||
return {
|
||
icon: value.icon,
|
||
label: isEnglish(locale) ? value.en : value.zh,
|
||
};
|
||
}
|
||
}
|
||
return { icon: "🔍", label: metarCode };
|
||
}
|
||
|
||
export function getRiskBadgeLabel(
|
||
level?: string | null,
|
||
locale: Locale = "zh-CN",
|
||
) {
|
||
if (isEnglish(locale)) {
|
||
return (
|
||
{
|
||
high: "🔴 High Risk",
|
||
low: "🟢 Low Risk",
|
||
medium: "🟠 Medium Risk",
|
||
}[String(level || "low")] || "Unknown Risk"
|
||
);
|
||
}
|
||
return (
|
||
{
|
||
high: "🔴 高风险",
|
||
low: "🟢 低风险",
|
||
medium: "🟠 中风险",
|
||
}[String(level || "low")] || "未知风险"
|
||
);
|
||
}
|
||
|
||
export function getWeatherSummary(detail: CityDetail, locale: Locale = "zh-CN") {
|
||
const current = detail.current || {};
|
||
const cloud = normalizeCloudSummary(current.cloud_desc, locale);
|
||
let weatherText = cloud.text;
|
||
let weatherIcon = cloud.icon;
|
||
|
||
if (current.wx_desc) {
|
||
const translated = translateMetar(current.wx_desc, locale);
|
||
if (translated) {
|
||
weatherText = translated.label;
|
||
weatherIcon = translated.icon;
|
||
}
|
||
}
|
||
|
||
return { weatherIcon, weatherText };
|
||
}
|
||
|
||
function normalizeHm(value?: 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")}`;
|
||
}
|
||
|
||
function hmToMinutes(value?: string | null) {
|
||
const normalized = normalizeHm(value);
|
||
if (!normalized) return null;
|
||
const [hourText, minuteText] = normalized.split(":");
|
||
const hour = Number.parseInt(hourText || "", 10);
|
||
const minute = Number.parseInt(minuteText || "", 10);
|
||
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
||
return hour * 60 + minute;
|
||
}
|
||
|
||
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 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),
|
||
},
|
||
];
|
||
}
|
||
|
||
function interpolateSeriesAtMinutes(
|
||
times: string[],
|
||
values: Array<number | null | undefined>,
|
||
currentMinutes: number,
|
||
) {
|
||
const points = times
|
||
.map((time, index) => {
|
||
const minute = hmToMinutes(time);
|
||
const value = values[index];
|
||
return minute != null && value != null && Number.isFinite(Number(value))
|
||
? { minute, value: Number(value) }
|
||
: null;
|
||
})
|
||
.filter((point): point is { minute: number; value: number } => point != null);
|
||
|
||
if (!points.length) return null;
|
||
|
||
const exact = points.find((point) => point.minute === currentMinutes);
|
||
if (exact) return exact.value;
|
||
|
||
let left: { minute: number; value: number } | null = null;
|
||
let right: { minute: number; value: number } | null = null;
|
||
|
||
for (const point of points) {
|
||
if (point.minute < currentMinutes) {
|
||
left = point;
|
||
continue;
|
||
}
|
||
if (point.minute > currentMinutes) {
|
||
right = point;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (left && right) {
|
||
const span = right.minute - left.minute;
|
||
if (span <= 0) return left.value;
|
||
const ratio = (currentMinutes - left.minute) / span;
|
||
return Number((left.value + (right.value - left.value) * ratio).toFixed(1));
|
||
}
|
||
if (left) return left.value;
|
||
if (right) return right.value;
|
||
return null;
|
||
}
|
||
|
||
export function getTodayPaceView(
|
||
detail: CityDetail,
|
||
locale: Locale = "zh-CN",
|
||
) {
|
||
const hourly = detail.hourly || {};
|
||
const times = hourly.times || [];
|
||
const temps = hourly.temps || [];
|
||
if (!times.length || !temps.length) return null;
|
||
|
||
const currentMinutes =
|
||
hmToMinutes(detail.local_time) ??
|
||
hmToMinutes(detail.airport_primary?.obs_time) ??
|
||
hmToMinutes(detail.airport_current?.obs_time) ??
|
||
hmToMinutes(detail.current?.obs_time);
|
||
if (currentMinutes == null) return null;
|
||
|
||
const omHigh = Number(detail.forecast?.today_high);
|
||
const debHigh = Number(detail.deb?.prediction);
|
||
const useDebOffset = Number.isFinite(omHigh) && Number.isFinite(debHigh);
|
||
const offset = useDebOffset ? debHigh - omHigh : 0;
|
||
const expectedSeries = temps.map((temp) =>
|
||
temp != null && Number.isFinite(Number(temp))
|
||
? Number((Number(temp) + offset).toFixed(1))
|
||
: null,
|
||
);
|
||
const expectedNow = interpolateSeriesAtMinutes(times, expectedSeries, currentMinutes);
|
||
if (expectedNow == null) return null;
|
||
|
||
const observedNowCandidate = [
|
||
detail.airport_primary?.temp,
|
||
detail.airport_current?.temp,
|
||
detail.current?.temp,
|
||
]
|
||
.map((value) => Number(value))
|
||
.find((value) => Number.isFinite(value));
|
||
if (observedNowCandidate == null) return null;
|
||
const observedNow = Number(observedNowCandidate);
|
||
|
||
const delta = Number((observedNow - expectedNow).toFixed(1));
|
||
const biasMagnitude = Math.abs(delta);
|
||
const biasTone =
|
||
delta >= 0.6 ? "warm" : delta <= -0.6 ? "cold" : "neutral";
|
||
const badge =
|
||
biasTone === "warm"
|
||
? isEnglish(locale)
|
||
? "Running hot"
|
||
: "跑得偏热"
|
||
: biasTone === "cold"
|
||
? isEnglish(locale)
|
||
? "Running cool"
|
||
: "跑得偏冷"
|
||
: isEnglish(locale)
|
||
? "On track"
|
||
: "基本跟踪";
|
||
const kicker = isEnglish(locale)
|
||
? `As of ${normalizeHm(detail.local_time) || detail.local_time || "--:--"}`
|
||
: `截至 ${normalizeHm(detail.local_time) || detail.local_time || "--:--"}`;
|
||
const deltaText =
|
||
delta === 0
|
||
? isEnglish(locale)
|
||
? "0.0°C vs expected"
|
||
: "0.0°C 相对预期"
|
||
: `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`;
|
||
|
||
const topObservedCandidate = [
|
||
detail.airport_primary?.max_so_far,
|
||
detail.airport_current?.max_so_far,
|
||
detail.current?.max_so_far,
|
||
observedNow,
|
||
]
|
||
.map((value) => Number(value))
|
||
.find((value) => Number.isFinite(value));
|
||
const topObserved = topObservedCandidate != null ? Number(topObservedCandidate) : null;
|
||
const projectedBase = Number.isFinite(debHigh)
|
||
? debHigh
|
||
: Number.isFinite(omHigh)
|
||
? omHigh
|
||
: null;
|
||
const paceAdjustedHigh =
|
||
projectedBase != null
|
||
? Number(
|
||
Math.max(projectedBase + delta, topObserved ?? projectedBase).toFixed(1),
|
||
)
|
||
: topObserved;
|
||
const paceAdjustedLabel = isEnglish(locale)
|
||
? "Pace-adjusted high"
|
||
: "节奏修正高点";
|
||
const peakWindowText =
|
||
Number.isFinite(Number(detail.peak?.first_h)) &&
|
||
Number.isFinite(Number(detail.peak?.last_h))
|
||
? `${String(Number(detail.peak?.first_h)).padStart(2, "0")}:00-${String(
|
||
Number(detail.peak?.last_h) + 1,
|
||
).padStart(2, "0")}:00`
|
||
: "--";
|
||
const observedLabel =
|
||
detail.airport_primary?.temp != null || detail.airport_current?.temp != null
|
||
? isEnglish(locale)
|
||
? "Airport obs"
|
||
: "机场实测"
|
||
: isEnglish(locale)
|
||
? "Current obs"
|
||
: "当前实测";
|
||
|
||
const paceSummary =
|
||
biasTone === "warm"
|
||
? isEnglish(locale)
|
||
? `The airport anchor is ${biasMagnitude.toFixed(1)}°C above the intraday curve. If that bias survives into the peak window, the day high is more likely to lean hotter than the current DEB path.`
|
||
: `机场主站当前比盘中曲线高 ${biasMagnitude.toFixed(1)}°C。若这段偏热节奏延续进峰值窗口,日高更容易落在当前 DEB 路径之上。`
|
||
: biasTone === "cold"
|
||
? isEnglish(locale)
|
||
? `The airport anchor is ${biasMagnitude.toFixed(1)}°C below the intraday curve. If that drag survives into the peak window, chasing higher buckets becomes harder.`
|
||
: `机场主站当前比盘中曲线低 ${biasMagnitude.toFixed(1)}°C。若这段偏冷节奏延续进峰值窗口,继续追更高温区间会更吃力。`
|
||
: isEnglish(locale)
|
||
? "The airport anchor is still tracking the intraday curve. Let later pace and peak-window structure decide."
|
||
: "机场主站当前仍基本贴着盘中曲线运行,后续主要看峰值窗口内的节奏有没有进一步偏离。";
|
||
|
||
const clamped = Math.min(Math.max(delta, -4), 4);
|
||
const meterLeft =
|
||
biasTone === "neutral"
|
||
? 46
|
||
: clamped >= 0
|
||
? 50
|
||
: 50 - (Math.abs(clamped) / 4) * 50;
|
||
const meterWidth =
|
||
biasTone === "neutral" ? 8 : Math.max((Math.abs(clamped) / 4) * 50, 8);
|
||
|
||
return {
|
||
badge,
|
||
biasTone,
|
||
delta,
|
||
deltaText,
|
||
expectedNow,
|
||
kicker,
|
||
meterLeft,
|
||
meterWidth,
|
||
observedLabel,
|
||
observedNow,
|
||
paceAdjustedHigh,
|
||
paceAdjustedLabel,
|
||
peakWindowText,
|
||
summary: paceSummary,
|
||
topObserved,
|
||
};
|
||
}
|
||
|
||
export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
|
||
const current = detail.current || {};
|
||
const parts: string[] = [];
|
||
const sourceTag = getRealtimeObservationTag(detail);
|
||
const suppressAnkaraMgmObservation = isTurkishMgmCity(detail);
|
||
|
||
if (current.obs_time) {
|
||
const ageText =
|
||
current.obs_age_min != null && current.obs_age_min >= 30
|
||
? isEnglish(locale)
|
||
? ` (${current.obs_age_min} min ago)`
|
||
: `(${current.obs_age_min} 分钟前)`
|
||
: "";
|
||
parts.push(`✈️ ${sourceTag} ${current.obs_time}${ageText}`);
|
||
}
|
||
|
||
if (current.wx_desc) {
|
||
const translated = translateMetar(current.wx_desc, locale);
|
||
if (translated) {
|
||
parts.push(`${translated.icon} ${translated.label}`);
|
||
}
|
||
} else if (current.cloud_desc) {
|
||
const cloud = normalizeCloudSummary(current.cloud_desc, locale);
|
||
parts.push(`${cloud.icon} ${cloud.text}`);
|
||
}
|
||
|
||
if (current.wind_speed_kt != null) {
|
||
parts.push(`💨 ${current.wind_speed_kt}kt`);
|
||
}
|
||
|
||
if (current.visibility_mi != null) {
|
||
parts.push(`👁️ ${current.visibility_mi}mi`);
|
||
}
|
||
|
||
if (!suppressAnkaraMgmObservation && detail.mgm?.temp != null) {
|
||
const timeMatch = detail.mgm.time?.match(/T?(\d{2}:\d{2})/);
|
||
const timeText = timeMatch ? ` @${timeMatch[1]}` : "";
|
||
parts.push(
|
||
isEnglish(locale)
|
||
? `🛰 MGM Obs: ${detail.mgm.temp}${detail.temp_symbol}${timeText}`
|
||
: `🛰 MGM 实测: ${detail.mgm.temp}${detail.temp_symbol}${timeText}`,
|
||
);
|
||
}
|
||
|
||
const trend = detail.trend || {};
|
||
if (trend.is_dead_market) {
|
||
parts.push(isEnglish(locale) ? "☠️ Flat market" : "☠️ 死盘");
|
||
} else if (trend.direction && trend.direction !== "unknown") {
|
||
const labels: Record<string, string> = isEnglish(locale)
|
||
? {
|
||
falling: "📉 Cooling",
|
||
mixed: "📊 Choppy",
|
||
rising: "📈 Warming",
|
||
stagnant: "⏸ Flat",
|
||
}
|
||
: {
|
||
falling: "📉 降温中",
|
||
mixed: "📊 波动中",
|
||
rising: "📈 升温中",
|
||
stagnant: "⏸ 持平",
|
||
};
|
||
parts.push(labels[trend.direction] || trend.direction);
|
||
}
|
||
|
||
return parts;
|
||
}
|
||
|
||
export function getTemperatureChartData(
|
||
detail: CityDetail,
|
||
locale: Locale = "zh-CN",
|
||
) {
|
||
const hourly = detail.hourly || {};
|
||
const times = hourly.times || [];
|
||
const temps = hourly.temps || [];
|
||
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((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 = item.temp ?? null;
|
||
if (index >= 0 && temp != null) {
|
||
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), Number(temp));
|
||
}
|
||
});
|
||
const airportMetarPoints = new Array(times.length).fill(null);
|
||
airportMetarSource.forEach((item) => {
|
||
const index = findNearestTimeIndex(times, String(item.time || ""));
|
||
const temp = item.temp ?? null;
|
||
if (index >= 0 && temp != null) {
|
||
const existing = airportMetarPoints[index];
|
||
airportMetarPoints[index] =
|
||
existing == null ? temp : Math.max(Number(existing), Number(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);
|
||
if (index >= 0) {
|
||
mgmPoints[index] = detail.mgm.temp;
|
||
}
|
||
}
|
||
|
||
const mgmHourlyPoints = new Array(times.length).fill(null);
|
||
let hasMgmHourly = false;
|
||
detail.mgm?.hourly?.forEach((item) => {
|
||
const index = findNearestTimeIndex(times, String(item.time || ""));
|
||
if (index >= 0) {
|
||
mgmHourlyPoints[index] = item.temp ?? null;
|
||
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 min = Math.floor(Math.min(...allValues)) - 1;
|
||
const max = Math.ceil(Math.max(...allValues)) + 1;
|
||
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,
|
||
};
|
||
}
|
||
|
||
export function getProbabilityView(detail: CityDetail, targetDate?: string | null) {
|
||
const date = targetDate || detail.local_date;
|
||
if (date === detail.local_date) {
|
||
return {
|
||
calibrationMode: detail.probabilities?.calibration_mode ?? null,
|
||
calibrationVersion: detail.probabilities?.calibration_version ?? null,
|
||
engine: detail.probabilities?.engine ?? null,
|
||
mu: detail.probabilities?.mu ?? null,
|
||
probabilities: detail.probabilities?.distribution || [],
|
||
probabilitiesAll:
|
||
detail.probabilities?.distribution_all ||
|
||
detail.probabilities?.distribution ||
|
||
[],
|
||
shadowProbabilities: detail.probabilities?.shadow_distribution || [],
|
||
shadowProbabilitiesAll:
|
||
detail.probabilities?.shadow_distribution_all ||
|
||
detail.probabilities?.shadow_distribution ||
|
||
[],
|
||
};
|
||
}
|
||
|
||
const daily = detail.multi_model_daily?.[date];
|
||
return {
|
||
calibrationMode: null,
|
||
calibrationVersion: null,
|
||
engine: null,
|
||
mu: daily?.deb?.prediction ?? null,
|
||
probabilities: daily?.probabilities || [],
|
||
probabilitiesAll: daily?.probabilities || [],
|
||
shadowProbabilities: [],
|
||
shadowProbabilitiesAll: [],
|
||
};
|
||
}
|
||
|
||
export function getModelView(detail: CityDetail, targetDate?: string | null) {
|
||
const date = targetDate || detail.local_date;
|
||
const daily = detail.multi_model_daily?.[date];
|
||
if (daily) {
|
||
return {
|
||
deb: daily.deb?.prediction ?? null,
|
||
models: daily.models || {},
|
||
};
|
||
}
|
||
|
||
return {
|
||
deb: detail.deb?.prediction ?? null,
|
||
models: detail.multi_model || {},
|
||
};
|
||
}
|
||
|
||
export function parseAiAnalysis(analysis: CityDetail["ai_analysis"]) {
|
||
const fallback = {
|
||
bullets: [] as string[],
|
||
summary: "",
|
||
};
|
||
|
||
if (!analysis) return fallback;
|
||
|
||
if (typeof analysis === "string") {
|
||
return {
|
||
bullets: [],
|
||
summary: analysis.trim(),
|
||
};
|
||
}
|
||
|
||
const structured = analysis as AiAnalysisStructured;
|
||
return {
|
||
bullets: Array.isArray(structured.highlights)
|
||
? structured.highlights
|
||
: Array.isArray(structured.points)
|
||
? structured.points
|
||
: [],
|
||
summary: structured.summary || structured.text || structured.message || "",
|
||
};
|
||
}
|
||
|
||
export function getAirportNarrative(
|
||
detail: CityDetail,
|
||
locale: Locale = "zh-CN",
|
||
) {
|
||
const parsed = parseAiAnalysis(detail.ai_analysis);
|
||
if (!isEnglish(locale)) return parsed;
|
||
|
||
const englishSummary = containsCjk(parsed.summary) ? "" : parsed.summary.trim();
|
||
const englishBullets = parsed.bullets
|
||
.map((item) => String(item || "").trim())
|
||
.filter((item) => item && !containsCjk(item));
|
||
|
||
if (englishSummary || englishBullets.length > 0) {
|
||
return {
|
||
bullets: englishBullets,
|
||
summary: englishSummary,
|
||
};
|
||
}
|
||
|
||
const sourceLabel =
|
||
String(detail.current?.settlement_source_label || "").trim() ||
|
||
String(detail.risk?.icao || "").trim() ||
|
||
String(detail.risk?.airport || "").trim() ||
|
||
String(detail.display_name || detail.name || "").trim() ||
|
||
"Airport";
|
||
const currentTemp = Number(detail.current?.temp);
|
||
const tempText = Number.isFinite(currentTemp)
|
||
? `${currentTemp}${detail.temp_symbol || "°C"}`
|
||
: null;
|
||
const obsTime = String(detail.current?.obs_time || "").trim();
|
||
const weatherText = getWeatherSummary(detail, locale).weatherText.toLowerCase();
|
||
const windBucket = bucketLabel(
|
||
trendBucketFromDir(detail.current?.wind_dir ?? null),
|
||
locale,
|
||
);
|
||
const windSpeedKt = Number(detail.current?.wind_speed_kt);
|
||
const tafSummary = String(detail.taf?.signal?.summary_en || "").trim();
|
||
const windPhrase = Number.isFinite(windSpeedKt)
|
||
? `${windBucket} around ${windSpeedKt} kt`
|
||
: `${windBucket} prevailing`;
|
||
const summaryParts = [
|
||
tempText
|
||
? `${sourceLabel} reports ${tempText}${obsTime ? ` at ${obsTime}` : ""}, ${weatherText}.`
|
||
: `${sourceLabel} reports ${weatherText}${obsTime ? ` at ${obsTime}` : ""}.`,
|
||
`${windPhrase}.`,
|
||
tafSummary,
|
||
].filter(Boolean);
|
||
|
||
const bullets: string[] = [];
|
||
const rawMetar = String(detail.current?.raw_metar || "").trim();
|
||
if (rawMetar) {
|
||
bullets.push(`Latest METAR: ${rawMetar}`);
|
||
}
|
||
if (tafSummary) {
|
||
bullets.push(`TAF signal: ${tafSummary}`);
|
||
}
|
||
if (detail.taf?.raw_taf) {
|
||
bullets.push(`TAF available for airport-side timing checks.`);
|
||
}
|
||
|
||
return {
|
||
bullets,
|
||
summary: summaryParts.join(" "),
|
||
};
|
||
}
|
||
|
||
export function pickAnkaraNearbyStations(stations: NearbyStation[]) {
|
||
const preferredNames = [
|
||
"Airport (MGM/17128)",
|
||
"Ankara (Bölge/Center)",
|
||
"Ankara (Bolge/Center)",
|
||
"Etimesgut",
|
||
"Pursaklar",
|
||
"Cubuk",
|
||
"Çubuk",
|
||
"Kalecik",
|
||
];
|
||
|
||
const picks = preferredNames
|
||
.map((name) => stations.find((station) => station?.name === name))
|
||
.filter(Boolean) as NearbyStation[];
|
||
|
||
return picks.length ? picks : stations;
|
||
}
|
||
|
||
function distanceKm(
|
||
lat1: number,
|
||
lon1: number,
|
||
lat2: number,
|
||
lon2: number,
|
||
) {
|
||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||
const dLat = toRad(lat2 - lat1);
|
||
const dLon = toRad(lon2 - lon1);
|
||
const a =
|
||
Math.sin(dLat / 2) ** 2 +
|
||
Math.cos(toRad(lat1)) *
|
||
Math.cos(toRad(lat2)) *
|
||
Math.sin(dLon / 2) ** 2;
|
||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||
}
|
||
|
||
export function pickMapNearbyStations(detail: CityDetail) {
|
||
const stations = Array.isArray(detail.official_nearby)
|
||
? detail.official_nearby
|
||
: Array.isArray(detail.mgm_nearby)
|
||
? detail.mgm_nearby
|
||
: [];
|
||
const city = String(detail.name || detail.display_name || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
|
||
if (city === "ankara") {
|
||
return pickAnkaraNearbyStations(stations);
|
||
}
|
||
|
||
if (city === "istanbul" && Number.isFinite(detail.lat) && Number.isFinite(detail.lon)) {
|
||
const preferredTokens = [
|
||
"havalimani",
|
||
"havalimanı",
|
||
"arnavutkoy",
|
||
"arnavutköy",
|
||
"liman feneri",
|
||
];
|
||
const scored = stations
|
||
.map((station) => {
|
||
const lat = Number(station.lat);
|
||
const lon = Number(station.lon);
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||
return null;
|
||
}
|
||
const name = String(station.name || "").toLowerCase();
|
||
const preferred = preferredTokens.some((token) => name.includes(token));
|
||
return {
|
||
preferred,
|
||
station,
|
||
km: distanceKm(Number(detail.lat), Number(detail.lon), lat, lon),
|
||
};
|
||
})
|
||
.filter(Boolean) as Array<{
|
||
preferred: boolean;
|
||
station: NearbyStation;
|
||
km: number;
|
||
}>;
|
||
|
||
const closePreferred = scored
|
||
.filter((row) => row.preferred && row.km <= 18)
|
||
.sort((a, b) => a.km - b.km)
|
||
.map((row) => row.station);
|
||
|
||
const closeFallback = scored
|
||
.filter((row) => row.km <= 8)
|
||
.sort((a, b) => a.km - b.km)
|
||
.map((row) => row.station);
|
||
|
||
const merged = [...closePreferred, ...closeFallback].filter(
|
||
(station, index, list) =>
|
||
list.findIndex(
|
||
(row) =>
|
||
row.name === station.name &&
|
||
row.lat === station.lat &&
|
||
row.lon === station.lon,
|
||
) === index,
|
||
);
|
||
|
||
return merged.slice(0, 3);
|
||
}
|
||
|
||
return stations;
|
||
}
|
||
|
||
export function getFutureSlice(detail: CityDetail, dateStr: string) {
|
||
const hourly = detail.hourly_next_48h || {};
|
||
const times = hourly.times || [];
|
||
const slice: Array<{
|
||
cloudCover: number | null;
|
||
dewPoint: number | null;
|
||
label: string;
|
||
precipProb: number | null;
|
||
pressure: number | null;
|
||
radiation: number | null;
|
||
temp: number | null;
|
||
time: string;
|
||
windDir: number | null;
|
||
windSpeed: number | null;
|
||
}> = [];
|
||
|
||
for (let index = 0; index < times.length; index += 1) {
|
||
const timestamp = times[index];
|
||
if (!timestamp || !String(timestamp).startsWith(dateStr)) continue;
|
||
|
||
slice.push({
|
||
cloudCover: hourly.cloud_cover?.[index] ?? null,
|
||
dewPoint: hourly.dew_point?.[index] ?? null,
|
||
label: String(timestamp).split("T")[1]?.slice(0, 5) || timestamp,
|
||
precipProb: hourly.precipitation_probability?.[index] ?? null,
|
||
pressure: hourly.pressure_msl?.[index] ?? null,
|
||
radiation: hourly.radiation?.[index] ?? null,
|
||
temp: hourly.temps?.[index] ?? null,
|
||
time: timestamp,
|
||
windDir: hourly.wind_direction_10m?.[index] ?? null,
|
||
windSpeed: hourly.wind_speed_10m?.[index] ?? null,
|
||
});
|
||
}
|
||
|
||
return slice;
|
||
}
|
||
|
||
function trendBucketFromDir(direction?: number | null) {
|
||
const value = Number(direction);
|
||
if (!Number.isFinite(value)) return null;
|
||
if (value >= 135 && value <= 240) return "southerly";
|
||
if (value >= 290 || value <= 45) return "northerly";
|
||
if (value > 45 && value < 135) return "easterly";
|
||
return "westerly";
|
||
}
|
||
|
||
function bucketLabel(bucket: string | null, locale: Locale = "zh-CN") {
|
||
if (isEnglish(locale)) {
|
||
return (
|
||
{
|
||
southerly: "S / SW wind",
|
||
northerly: "N / NW wind",
|
||
easterly: "E wind",
|
||
westerly: "W wind",
|
||
}[bucket || ""] || "Unknown wind direction"
|
||
);
|
||
}
|
||
return (
|
||
{
|
||
southerly: "南 / 西南风",
|
||
northerly: "北 / 西北风",
|
||
easterly: "东风",
|
||
westerly: "西风",
|
||
}[bucket || ""] || "风向不明"
|
||
);
|
||
}
|
||
|
||
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) {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return null;
|
||
return numeric >= 0
|
||
? Math.floor(numeric + 0.5)
|
||
: Math.ceil(numeric - 0.5);
|
||
}
|
||
|
||
export function formatDelta(value: number | null | undefined, suffix = "") {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return "--";
|
||
const sign = numeric > 0 ? "+" : "";
|
||
return `${sign}${numeric.toFixed(1)}${suffix}`;
|
||
}
|
||
|
||
function getForecastTextForDate(detail: CityDetail, dateStr: string) {
|
||
const periods = detail.source_forecasts?.weather_gov?.forecast_periods || [];
|
||
return periods.filter((period) =>
|
||
String(period.start_time || "").startsWith(dateStr),
|
||
);
|
||
}
|
||
|
||
export function computeFrontTrendSignal(
|
||
detail: CityDetail,
|
||
dateStr: string,
|
||
locale: Locale = "zh-CN",
|
||
) {
|
||
const upperAirSignal = detail.vertical_profile_signal || {};
|
||
const tafSignal = detail.taf?.signal || {};
|
||
const upperAirTradeCue = upperAirSignal.source
|
||
? upperAirSignal.heating_setup === "supportive"
|
||
? {
|
||
label: isEnglish(locale) ? "Trade cue" : "交易动作",
|
||
note: isEnglish(locale)
|
||
? "The setup still supports further warming. Do not call the high too early."
|
||
: "结构仍支持继续升温,别太早押高温见顶。",
|
||
tone: "warm",
|
||
value: isEnglish(locale) ? "Lean warmer" : "偏暖侧",
|
||
}
|
||
: upperAirSignal.heating_setup === "suppressed"
|
||
? {
|
||
label: isEnglish(locale) ? "Trade cue" : "交易动作",
|
||
note: isEnglish(locale)
|
||
? "Further upside looks less reliable. Do not chase the high blindly."
|
||
: "高温继续上冲的把握不大,别盲目追热。",
|
||
tone: "cold",
|
||
value: isEnglish(locale) ? "Lean cautious" : "偏谨慎",
|
||
}
|
||
: {
|
||
label: isEnglish(locale) ? "Trade cue" : "交易动作",
|
||
note: isEnglish(locale)
|
||
? "The picture is still mixed. Let the next move decide first."
|
||
: "现在还看不出明确方向,先等下一步走势确认。",
|
||
tone: "",
|
||
value: isEnglish(locale) ? "Wait / confirm" : "先观察",
|
||
}
|
||
: null;
|
||
const baseUpperAirSummary = upperAirSignal.source
|
||
? (() => {
|
||
const hasMetrics =
|
||
upperAirSignal.cape_max != null ||
|
||
upperAirSignal.cin_min != null ||
|
||
upperAirSignal.boundary_layer_height_max != null ||
|
||
upperAirSignal.shear_10m_180m_max != null;
|
||
if (!hasMetrics) {
|
||
return isEnglish(locale)
|
||
? "Upper-air inputs are incomplete. For now, trade direction should rely more on surface structure."
|
||
: "高空输入还不完整,当前交易方向先更多参考近地面结构信号。";
|
||
}
|
||
if (upperAirSignal.heating_setup === "supportive") {
|
||
return isEnglish(locale)
|
||
? "Upper-air structure still favors further warming. Do not call the high too early."
|
||
: "高空结构仍偏向继续增温,别太早押高温见顶。";
|
||
}
|
||
if (upperAirSignal.heating_setup === "suppressed") {
|
||
return isEnglish(locale)
|
||
? "Upper-air structure leans toward capping the afternoon high. Further upside looks less reliable."
|
||
: "高空结构更偏向压住午后峰值,高温继续上冲的把握不大。";
|
||
}
|
||
return isEnglish(locale)
|
||
? "Upper-air structure is fairly neutral. It does not provide a clean edge yet."
|
||
: "高空结构整体偏中性,暂时还给不出明确方向。";
|
||
})()
|
||
: "";
|
||
const tafSummary =
|
||
tafSignal.available && dateStr === detail.local_date
|
||
? isEnglish(locale)
|
||
? String(tafSignal.summary_en || "").trim()
|
||
: String(tafSignal.summary_zh || "").trim()
|
||
: "";
|
||
let upperAirSummary = baseUpperAirSummary;
|
||
let tafMetric:
|
||
| {
|
||
label: string;
|
||
note: string;
|
||
tone?: string;
|
||
value: string;
|
||
}
|
||
| null = null;
|
||
let upperAirMetrics: Array<{
|
||
label: string;
|
||
note: string;
|
||
tone?: string;
|
||
value: string;
|
||
}> = [];
|
||
const localizedCommentary =
|
||
dateStr === detail.local_date
|
||
? getLocalizedDynamicCommentary(detail, locale)
|
||
: { headline: "", bullets: [], source: "" };
|
||
const backendSummary =
|
||
localizedCommentary.headline &&
|
||
(!isEnglish(locale) || !containsCjk(localizedCommentary.headline))
|
||
? localizedCommentary.headline
|
||
: "";
|
||
const backendNotes = localizedCommentary.bullets.filter(
|
||
(note) => !isEnglish(locale) || !containsCjk(note),
|
||
);
|
||
const slice = getFutureSlice(detail, dateStr);
|
||
const currentTemp = Number(detail.current?.temp);
|
||
const currentDew = Number(detail.current?.dewpoint);
|
||
|
||
if (!slice.length) {
|
||
const fallbackSummary =
|
||
backendSummary ||
|
||
(isEnglish(locale)
|
||
? "Insufficient intraday structured data. Keep baseline monitoring."
|
||
: "当日日内结构化数据不足,暂时只保留基础监控。");
|
||
return {
|
||
confidence: "low",
|
||
label: isEnglish(locale) ? "Monitoring" : "监控中",
|
||
metrics: [] as Array<{
|
||
label: string;
|
||
note: string;
|
||
tone?: string;
|
||
value: string;
|
||
}>,
|
||
upperAirMetrics,
|
||
upperAirSummary,
|
||
precipMax: 0,
|
||
score: 0,
|
||
summary: fallbackSummary,
|
||
summaryLines: [fallbackSummary],
|
||
weatherGovPeriods: [] as ReturnType<typeof getForecastTextForDate>,
|
||
};
|
||
}
|
||
|
||
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 pointMinutes = (point: { label?: string }) =>
|
||
hmToMinutes(normalizeHm(point.label));
|
||
|
||
const isTargetToday = dateStr === detail.local_date;
|
||
const currentHm = normalizeHm(detail.local_time);
|
||
const sunsetHm = normalizeHm(detail.forecast?.sunset);
|
||
const peakFirstHour = Number(detail.peak?.first_h);
|
||
const peakLastHour = Number(detail.peak?.last_h);
|
||
const hasPeakWindow =
|
||
Number.isFinite(peakFirstHour) &&
|
||
Number.isFinite(peakLastHour) &&
|
||
peakFirstHour >= 0 &&
|
||
peakLastHour >= peakFirstHour;
|
||
const currentMinutes = hmToMinutes(currentHm);
|
||
const sunsetMinutes = hmToMinutes(sunsetHm);
|
||
const peakWindowStartMinutes = hasPeakWindow
|
||
? Math.max(0, (peakFirstHour - 2) * 60)
|
||
: null;
|
||
const peakWindowEndMinutes = hasPeakWindow
|
||
? Math.min(23 * 60 + 59, (peakLastHour + 1) * 60)
|
||
: null;
|
||
const canUseSunsetWindow =
|
||
isTargetToday &&
|
||
currentMinutes !== null &&
|
||
sunsetMinutes !== null &&
|
||
sunsetMinutes > currentMinutes;
|
||
const tafMarkers = Array.isArray(tafSignal.markers) ? tafSignal.markers : [];
|
||
const markerSummary = (
|
||
marker:
|
||
| {
|
||
marker_type?: string | null;
|
||
start_local?: string | null;
|
||
end_local?: string | null;
|
||
suppression_level?: string | null;
|
||
summary_zh?: string | null;
|
||
summary_en?: string | null;
|
||
}
|
||
| null
|
||
| undefined,
|
||
) =>
|
||
!marker
|
||
? ""
|
||
: isEnglish(locale)
|
||
? String(marker.summary_en || "").trim()
|
||
: String(marker.summary_zh || "").trim();
|
||
const currentTafMarker =
|
||
tafSignal.available && currentMinutes !== null
|
||
? tafMarkers.find((marker) => {
|
||
const start = hmToMinutes(normalizeHm(marker?.start_local));
|
||
const end = hmToMinutes(normalizeHm(marker?.end_local));
|
||
if (start === null || end === null) return false;
|
||
return currentMinutes >= start && currentMinutes <= end;
|
||
})
|
||
: null;
|
||
const nextTafMarker =
|
||
tafSignal.available && currentMinutes !== null && !currentTafMarker
|
||
? tafMarkers.find((marker) => {
|
||
const start = hmToMinutes(normalizeHm(marker?.start_local));
|
||
return start !== null && start > currentMinutes;
|
||
})
|
||
: null;
|
||
const sameMarker = (
|
||
left:
|
||
| { marker_type?: string | null; start_local?: string | null; end_local?: string | null }
|
||
| null
|
||
| undefined,
|
||
right:
|
||
| { marker_type?: string | null; start_local?: string | null; end_local?: string | null }
|
||
| null
|
||
| undefined,
|
||
) =>
|
||
!!left &&
|
||
!!right &&
|
||
String(left.marker_type || "") === String(right.marker_type || "") &&
|
||
String(left.start_local || "") === String(right.start_local || "") &&
|
||
String(left.end_local || "") === String(right.end_local || "");
|
||
const formatTafSegmentBody = (
|
||
marker:
|
||
| {
|
||
marker_type?: string | null;
|
||
start_local?: string | null;
|
||
end_local?: string | null;
|
||
suppression_level?: string | null;
|
||
}
|
||
| null
|
||
| undefined,
|
||
kind: "current" | "next" | "peak",
|
||
) => {
|
||
if (!marker) return "";
|
||
const range = `${normalizeHm(marker.start_local) || "--:--"}-${normalizeHm(marker.end_local) || "--:--"}`;
|
||
const typeLabel = formatTafMarkerType(
|
||
String(marker.marker_type || ""),
|
||
locale,
|
||
);
|
||
const level = String(marker.suppression_level || "low").toLowerCase();
|
||
const statusText = isEnglish(locale)
|
||
? level === "high"
|
||
? "shows shower or thunderstorm disruption"
|
||
: level === "medium"
|
||
? "shows cloud or light-rain disruption"
|
||
: "stays relatively stable"
|
||
: level === "high"
|
||
? "有阵雨或雷暴扰动"
|
||
: level === "medium"
|
||
? "有云量或弱降水扰动"
|
||
: "以稳定为主";
|
||
return isEnglish(locale)
|
||
? `${typeLabel} (${range}), ${statusText}.`
|
||
: `${typeLabel}(${range}),${statusText}。`;
|
||
};
|
||
const peakWindowTafMarker =
|
||
tafSignal.available &&
|
||
peakWindowStartMinutes !== null &&
|
||
peakWindowEndMinutes !== null
|
||
? tafMarkers.find((marker) => {
|
||
const start = hmToMinutes(normalizeHm(marker?.start_local));
|
||
const end = hmToMinutes(normalizeHm(marker?.end_local));
|
||
if (start === null || end === null) return false;
|
||
return start <= peakWindowEndMinutes && end >= peakWindowStartMinutes;
|
||
})
|
||
: null;
|
||
const peakTafSummary = markerSummary(peakWindowTafMarker);
|
||
const peakTafStillRelevant =
|
||
peakWindowEndMinutes === null ||
|
||
currentMinutes === null ||
|
||
currentMinutes < peakWindowEndMinutes;
|
||
const effectivePeakTafSummary =
|
||
peakTafStillRelevant &&
|
||
peakTafSummary &&
|
||
!sameMarker(peakWindowTafMarker, currentTafMarker) &&
|
||
!sameMarker(peakWindowTafMarker, nextTafMarker)
|
||
? peakTafSummary
|
||
: "";
|
||
const currentTafLine = currentTafMarker
|
||
? isEnglish(locale)
|
||
? `Use current TAF as primary: ${formatTafSegmentBody(currentTafMarker, "current")}`
|
||
: `以当前 TAF 为准:${formatTafSegmentBody(currentTafMarker, "current")}`
|
||
: nextTafMarker
|
||
? isEnglish(locale)
|
||
? `Use next TAF segment as primary: ${formatTafSegmentBody(nextTafMarker, "next")}`
|
||
: `以下一段 TAF 为准:${formatTafSegmentBody(nextTafMarker, "next")}`
|
||
: "";
|
||
const currentTafLineForSummary = currentTafLine.replace(/^Use (current|next) TAF (as primary|segment as primary):\s*/i, "").replace(/^以(当前|下一段) TAF 为准:/, "");
|
||
const peakTafLine =
|
||
effectivePeakTafSummary && peakWindowTafMarker
|
||
? isEnglish(locale)
|
||
? `Peak-window reference: ${formatTafSegmentBody(peakWindowTafMarker, "peak")}`
|
||
: `峰值窗口参考:${formatTafSegmentBody(peakWindowTafMarker, "peak")}`
|
||
: "";
|
||
const peakTafLineForSummary = peakTafLine.replace(/^Peak-window reference:\s*/i, "").replace(/^峰值窗口参考:/, "");
|
||
const tafFallbackSummary =
|
||
currentTafLine || peakTafLine ? "" : tafSummary;
|
||
const tafPrimarySummary = currentTafMarker
|
||
? isEnglish(locale)
|
||
? `Use current TAF as primary: ${currentTafLineForSummary}`
|
||
: `以当前 TAF 为准:${currentTafLineForSummary}`
|
||
: nextTafMarker
|
||
? isEnglish(locale)
|
||
? `Use next TAF segment as primary: ${currentTafLineForSummary}`
|
||
: `以下一段 TAF 为准:${currentTafLineForSummary}`
|
||
: "";
|
||
const tafReferenceSummary =
|
||
peakTafLineForSummary &&
|
||
peakTafLineForSummary !== currentTafLineForSummary
|
||
? isEnglish(locale)
|
||
? `Peak-window reference: ${peakTafLineForSummary}`
|
||
: `峰值窗口参考:${peakTafLineForSummary}`
|
||
: "";
|
||
upperAirSummary = [
|
||
baseUpperAirSummary,
|
||
tafPrimarySummary,
|
||
tafReferenceSummary,
|
||
tafFallbackSummary,
|
||
]
|
||
.filter(Boolean)
|
||
.join(isEnglish(locale) ? " " : "");
|
||
tafMetric =
|
||
tafSignal.available && dateStr === detail.local_date
|
||
? {
|
||
label: isEnglish(locale) ? "Airport TAF" : "机场预报",
|
||
note:
|
||
tafPrimarySummary ||
|
||
tafReferenceSummary ||
|
||
tafFallbackSummary ||
|
||
(isEnglish(locale)
|
||
? "Airport TAF is available for the current peak window."
|
||
: "当前峰值窗口已接入机场 TAF 预报。"),
|
||
tone:
|
||
tafSignal.suppression_level === "high"
|
||
? "cold"
|
||
: tafSignal.suppression_level === "low"
|
||
? "warm"
|
||
: "",
|
||
value:
|
||
tafSignal.suppression_level === "high"
|
||
? isEnglish(locale)
|
||
? "Suppression watch"
|
||
: "防压温"
|
||
: tafSignal.suppression_level === "medium"
|
||
? isEnglish(locale)
|
||
? "Watch clouds/rain"
|
||
: "看云雨"
|
||
: isEnglish(locale)
|
||
? "Mostly stable"
|
||
: "暂稳",
|
||
}
|
||
: null;
|
||
upperAirMetrics = upperAirSignal.source
|
||
? [
|
||
...(upperAirTradeCue ? [upperAirTradeCue] : []),
|
||
{
|
||
label: isEnglish(locale) ? "Peak setup" : "冲高环境",
|
||
note:
|
||
upperAirSignal.heating_setup === "supportive"
|
||
? isEnglish(locale)
|
||
? "Still supportive of more daytime heating. Do not call the high too early."
|
||
: "结构仍支持白天继续升温,别太早押高温见顶。"
|
||
: upperAirSignal.heating_setup === "suppressed"
|
||
? isEnglish(locale)
|
||
? "Leans toward capping the afternoon peak. Further upside looks less reliable."
|
||
: "更偏向压住午后峰值,高温继续上冲的把握不大。"
|
||
: isEnglish(locale)
|
||
? "Neutral on its own. It does not provide a clear directional edge yet."
|
||
: "单看这层偏中性,暂时还没有明确方向优势。",
|
||
tone:
|
||
upperAirSignal.heating_setup === "supportive"
|
||
? "warm"
|
||
: upperAirSignal.heating_setup === "suppressed"
|
||
? "cold"
|
||
: "",
|
||
value:
|
||
upperAirSignal.heating_setup === "supportive"
|
||
? isEnglish(locale)
|
||
? "Supportive"
|
||
: "偏支持"
|
||
: upperAirSignal.heating_setup === "suppressed"
|
||
? isEnglish(locale)
|
||
? "Suppressed"
|
||
: "偏压制"
|
||
: isEnglish(locale)
|
||
? "Neutral"
|
||
: "中性",
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Peak suppression risk" : "压温风险",
|
||
note:
|
||
upperAirSignal.cape_max != null || upperAirSignal.cin_min != null
|
||
? isEnglish(locale)
|
||
? `How likely clouds or showers are to cap the high. CAPE ${Math.round(Number(upperAirSignal.cape_max ?? 0))}, CIN ${Number(upperAirSignal.cin_min ?? 0).toFixed(0)}.`
|
||
: `看云和阵雨有多大概率把峰值压住。CAPE ${Math.round(Number(upperAirSignal.cape_max ?? 0))},CIN ${Number(upperAirSignal.cin_min ?? 0).toFixed(0)}。`
|
||
: isEnglish(locale)
|
||
? "Estimated from the next 48h upper-air profile."
|
||
: "根据未来 48 小时高空剖面估算。",
|
||
tone:
|
||
upperAirSignal.suppression_risk === "high"
|
||
? "cold"
|
||
: upperAirSignal.suppression_risk === "low"
|
||
? "warm"
|
||
: "",
|
||
value:
|
||
upperAirSignal.suppression_risk === "high"
|
||
? isEnglish(locale)
|
||
? "High"
|
||
: "高"
|
||
: upperAirSignal.suppression_risk === "medium"
|
||
? isEnglish(locale)
|
||
? "Medium"
|
||
: "中"
|
||
: isEnglish(locale)
|
||
? "Low"
|
||
: "低",
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Afternoon disruption" : "午后扰动",
|
||
note:
|
||
upperAirSignal.lifted_index_min != null
|
||
? isEnglish(locale)
|
||
? `How easily the afternoon can turn noisy. Lifted Index ${Number(upperAirSignal.lifted_index_min).toFixed(1)}.`
|
||
: `看午后是否容易突然起云、起对流,把走势搅乱。Lifted Index ${Number(upperAirSignal.lifted_index_min).toFixed(1)}。`
|
||
: isEnglish(locale)
|
||
? "Uses instability and lifted-index structure."
|
||
: "结合不稳定能量与抬升指数判断。",
|
||
tone:
|
||
upperAirSignal.trigger_risk === "high"
|
||
? "cold"
|
||
: upperAirSignal.trigger_risk === "low"
|
||
? "warm"
|
||
: "",
|
||
value:
|
||
upperAirSignal.trigger_risk === "high"
|
||
? isEnglish(locale)
|
||
? "High"
|
||
: "高"
|
||
: upperAirSignal.trigger_risk === "medium"
|
||
? isEnglish(locale)
|
||
? "Medium"
|
||
: "中"
|
||
: isEnglish(locale)
|
||
? "Low"
|
||
: "低",
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Heating efficiency" : "冲高效率",
|
||
note:
|
||
upperAirSignal.boundary_layer_height_max != null
|
||
? isEnglish(locale)
|
||
? `How efficiently surface warmth can keep translating upward. Mixing depth peaks near ${Math.round(Number(upperAirSignal.boundary_layer_height_max))} m.`
|
||
: `看地面热量能不能持续往上送,决定冲高效率。混合层高度峰值约 ${Math.round(Number(upperAirSignal.boundary_layer_height_max))} 米。`
|
||
: isEnglish(locale)
|
||
? "Tracks daytime mixing depth."
|
||
: "跟踪白天混合层深度。",
|
||
tone:
|
||
upperAirSignal.mixing_strength === "strong"
|
||
? "warm"
|
||
: upperAirSignal.mixing_strength === "weak"
|
||
? "cold"
|
||
: "",
|
||
value:
|
||
upperAirSignal.mixing_strength === "strong"
|
||
? isEnglish(locale)
|
||
? "Strong"
|
||
: "强"
|
||
: upperAirSignal.mixing_strength === "medium"
|
||
? isEnglish(locale)
|
||
? "Medium"
|
||
: "中"
|
||
: isEnglish(locale)
|
||
? "Weak"
|
||
: "弱",
|
||
},
|
||
...(tafMetric ? [tafMetric] : []),
|
||
]
|
||
: tafMetric
|
||
? [tafMetric]
|
||
: [];
|
||
|
||
const futureSlice =
|
||
isTargetToday && currentMinutes !== null
|
||
? slice.filter((point) => {
|
||
const minutes = pointMinutes(point);
|
||
return minutes === null ? true : minutes >= currentMinutes;
|
||
})
|
||
: slice;
|
||
const untilSunsetSlice =
|
||
canUseSunsetWindow && sunsetMinutes !== null
|
||
? futureSlice.filter((point) => {
|
||
const minutes = pointMinutes(point);
|
||
return minutes === null ? false : minutes <= sunsetMinutes;
|
||
})
|
||
: futureSlice;
|
||
const aroundPeakSlice =
|
||
isTargetToday &&
|
||
peakWindowStartMinutes !== null &&
|
||
peakWindowEndMinutes !== null
|
||
? futureSlice.filter((point) => {
|
||
const minutes = pointMinutes(point);
|
||
return minutes === null
|
||
? false
|
||
: minutes >= peakWindowStartMinutes && minutes <= peakWindowEndMinutes;
|
||
})
|
||
: [];
|
||
const workingSlice =
|
||
aroundPeakSlice.length >= 2
|
||
? aroundPeakSlice
|
||
: untilSunsetSlice.length >= 2
|
||
? untilSunsetSlice
|
||
: futureSlice.length >= 2
|
||
? futureSlice
|
||
: slice;
|
||
const usingPeakWindow = aroundPeakSlice.length >= 2;
|
||
const usingSunsetWindow = canUseSunsetWindow && untilSunsetSlice.length >= 2;
|
||
const first = workingSlice[0] || slice[0];
|
||
const last = workingSlice[workingSlice.length - 1] || slice[slice.length - 1];
|
||
const effectiveHours = Math.max(1, workingSlice.length);
|
||
const windowLabel = `${first?.label || "--"}-${last?.label || "--"}`;
|
||
const windowText = isEnglish(locale)
|
||
? usingPeakWindow
|
||
? `today ${windowLabel} (~${effectiveHours}h, around peak window)`
|
||
: usingSunsetWindow
|
||
? `today ${windowLabel} (~${effectiveHours}h, now -> sunset)`
|
||
: isTargetToday
|
||
? `today ${windowLabel} (~${effectiveHours}h)`
|
||
: `daily ${windowLabel} (~${effectiveHours}h)`
|
||
: usingPeakWindow
|
||
? `今日 ${windowLabel}(约 ${effectiveHours} 小时,围绕峰值窗口)`
|
||
: usingSunsetWindow
|
||
? `今日 ${windowLabel}(约 ${effectiveHours} 小时,当前至日落)`
|
||
: isTargetToday
|
||
? `今日 ${windowLabel}(约 ${effectiveHours} 小时)`
|
||
: `当日日内 ${windowLabel}(约 ${effectiveHours} 小时)`;
|
||
const firstTemp = Number.isFinite(Number(first.temp)) ? Number(first.temp) : currentTemp;
|
||
const lastTemp = Number.isFinite(Number(last.temp)) ? Number(last.temp) : firstTemp;
|
||
const tempDelta =
|
||
Number.isFinite(firstTemp) && Number.isFinite(lastTemp) ? lastTemp - firstTemp : 0;
|
||
const firstDew = Number.isFinite(Number(first.dewPoint))
|
||
? Number(first.dewPoint)
|
||
: currentDew;
|
||
const lastDew = Number.isFinite(Number(last.dewPoint))
|
||
? Number(last.dewPoint)
|
||
: firstDew;
|
||
const dewDelta =
|
||
Number.isFinite(firstDew) && Number.isFinite(lastDew) ? lastDew - firstDew : 0;
|
||
const firstPressure = Number.isFinite(Number(first.pressure))
|
||
? Number(first.pressure)
|
||
: null;
|
||
const lastPressure = Number.isFinite(Number(last.pressure))
|
||
? Number(last.pressure)
|
||
: firstPressure;
|
||
const pressureDelta =
|
||
Number.isFinite(Number(firstPressure)) && Number.isFinite(Number(lastPressure))
|
||
? Number(lastPressure) - Number(firstPressure)
|
||
: 0;
|
||
const firstCloud = Number.isFinite(Number(first.cloudCover))
|
||
? Number(first.cloudCover)
|
||
: null;
|
||
const lastCloud = Number.isFinite(Number(last.cloudCover))
|
||
? Number(last.cloudCover)
|
||
: firstCloud;
|
||
const cloudDelta =
|
||
Number.isFinite(Number(firstCloud)) && Number.isFinite(Number(lastCloud))
|
||
? Number(lastCloud) - Number(firstCloud)
|
||
: 0;
|
||
const precipMax = slice.reduce(
|
||
(max, point) => Math.max(max, Number(point.precipProb) || 0),
|
||
0,
|
||
);
|
||
const precipWindowSource =
|
||
futureSlice.length >= 2 ? futureSlice : slice;
|
||
const precipCandidates = precipWindowSource
|
||
.map((point) => ({
|
||
label: normalizeHm(point.label),
|
||
minute: pointMinutes(point),
|
||
precip: Number(point.precipProb) || 0,
|
||
}))
|
||
.filter(
|
||
(point): point is { label: string; minute: number; precip: number } =>
|
||
point.label != null && point.minute != null,
|
||
);
|
||
const precipThreshold = precipMax >= 70 ? 50 : precipMax >= 40 ? 40 : 0;
|
||
const precipWindows: Array<{
|
||
start: number;
|
||
end: number;
|
||
startLabel: string;
|
||
endLabel: string;
|
||
peak: number;
|
||
}> = [];
|
||
if (precipThreshold > 0) {
|
||
let active:
|
||
| {
|
||
start: number;
|
||
end: number;
|
||
startLabel: string;
|
||
endLabel: string;
|
||
peak: number;
|
||
}
|
||
| null = null;
|
||
for (const point of precipCandidates) {
|
||
if (point.precip >= precipThreshold) {
|
||
if (!active) {
|
||
active = {
|
||
start: point.minute,
|
||
end: point.minute,
|
||
startLabel: point.label,
|
||
endLabel: point.label,
|
||
peak: point.precip,
|
||
};
|
||
} else {
|
||
active.end = point.minute;
|
||
active.endLabel = point.label;
|
||
active.peak = Math.max(active.peak, point.precip);
|
||
}
|
||
} else if (active) {
|
||
precipWindows.push(active);
|
||
active = null;
|
||
}
|
||
}
|
||
if (active) precipWindows.push(active);
|
||
}
|
||
const primaryPrecipWindow =
|
||
precipWindows.length > 0
|
||
? [...precipWindows].sort((left, right) => {
|
||
if (right.peak !== left.peak) return right.peak - left.peak;
|
||
return (right.end - right.start) - (left.end - left.start);
|
||
})[0]
|
||
: null;
|
||
const precipWindowLabel = primaryPrecipWindow
|
||
? `${primaryPrecipWindow.startLabel}-${primaryPrecipWindow.endLabel}`
|
||
: "";
|
||
const precipPeakOverlap =
|
||
primaryPrecipWindow &&
|
||
peakWindowStartMinutes != null &&
|
||
peakWindowEndMinutes != null
|
||
? Math.max(
|
||
0,
|
||
Math.min(primaryPrecipWindow.end, peakWindowEndMinutes) -
|
||
Math.max(primaryPrecipWindow.start, peakWindowStartMinutes),
|
||
)
|
||
: 0;
|
||
const precipOverlapLevel =
|
||
primaryPrecipWindow && peakWindowStartMinutes != null && peakWindowEndMinutes != null
|
||
? precipPeakOverlap >= 120
|
||
? "high"
|
||
: precipPeakOverlap > 0
|
||
? "medium"
|
||
: "low"
|
||
: "unknown";
|
||
const precipWindowSummary = (() => {
|
||
if (!primaryPrecipWindow) {
|
||
return isEnglish(locale)
|
||
? "No concentrated model precipitation window is visible yet."
|
||
: "模型里暂时还看不出明显集中的降水窗口。";
|
||
}
|
||
const overlapText = isEnglish(locale)
|
||
? precipOverlapLevel === "high"
|
||
? "It overlaps heavily with the peak window."
|
||
: precipOverlapLevel === "medium"
|
||
? "It overlaps part of the peak window."
|
||
: "It sits mostly outside the peak window."
|
||
: precipOverlapLevel === "high"
|
||
? "与峰值窗口重叠较高。"
|
||
: precipOverlapLevel === "medium"
|
||
? "与峰值窗口部分重叠。"
|
||
: "主要落在峰值窗口之外。";
|
||
return isEnglish(locale)
|
||
? `Model precipitation window is ${precipWindowLabel}, peaking near ${Math.round(primaryPrecipWindow.peak)}%. ${overlapText}`
|
||
: `模型降水窗口在 ${precipWindowLabel},峰值约 ${Math.round(primaryPrecipWindow.peak)}%。${overlapText}`;
|
||
})();
|
||
const firstBucket = trendBucketFromDir(first.windDir);
|
||
const lastBucket = trendBucketFromDir(last.windDir);
|
||
const weatherGovPeriods = getForecastTextForDate(detail, dateStr);
|
||
const weatherGovText = weatherGovPeriods
|
||
.map(
|
||
(period) =>
|
||
`${period.short_forecast || ""} ${period.detailed_forecast || ""}`.toLowerCase(),
|
||
)
|
||
.join(" ");
|
||
|
||
let warmScore = 0;
|
||
let coldScore = 0;
|
||
if (tempDelta >= 2) warmScore += 24;
|
||
else if (tempDelta >= 0.8) warmScore += 12;
|
||
if (tempDelta <= -2) coldScore += 24;
|
||
else if (tempDelta <= -0.8) coldScore += 12;
|
||
if (dewDelta >= 1.2) warmScore += 14;
|
||
if (dewDelta <= -1.2) coldScore += 10;
|
||
if (pressureDelta >= 1.2) coldScore += 16;
|
||
if (pressureDelta <= -1.0) warmScore += 8;
|
||
if (lastBucket === "southerly") warmScore += 14;
|
||
if (firstBucket !== lastBucket && lastBucket === "southerly") warmScore += 10;
|
||
if (lastBucket === "northerly") coldScore += 14;
|
||
if (firstBucket !== lastBucket && lastBucket === "northerly") coldScore += 10;
|
||
if (cloudDelta >= 15 && tempDelta >= 0) warmScore += 6;
|
||
if (cloudDelta >= 15 && tempDelta < 0) coldScore += 8;
|
||
if (precipMax >= 40) coldScore += 8;
|
||
if (
|
||
weatherGovText.includes("cold front") ||
|
||
weatherGovText.includes("temperatures falling")
|
||
) {
|
||
coldScore += 18;
|
||
}
|
||
if (weatherGovText.includes("warm front") || weatherGovText.includes("warmer")) {
|
||
warmScore += 18;
|
||
}
|
||
if (weatherGovText.includes("thunder") || weatherGovText.includes("snow")) {
|
||
coldScore += 8;
|
||
}
|
||
|
||
const score = Math.max(-100, Math.min(100, warmScore - coldScore));
|
||
const warmLabel = isEnglish(locale) ? "Near-term warming bias" : "未来偏升温";
|
||
const coldLabel = isEnglish(locale) ? "Near-term cooling bias" : "未来偏降温";
|
||
const monitorLabel = isEnglish(locale) ? "Direction unclear" : "方向不清";
|
||
const label = score >= 18 ? warmLabel : score <= -18 ? coldLabel : monitorLabel;
|
||
const confidence =
|
||
Math.abs(score) >= 45 ? "high" : Math.abs(score) >= 22 ? "medium" : "low";
|
||
const directionalLead = (() => {
|
||
if (isEnglish(locale)) {
|
||
if (score >= 18 && tempDelta >= 0.5) {
|
||
return `Over ${windowText}, temperatures still lean warmer.`;
|
||
}
|
||
if (score <= -18 && tempDelta <= -0.5) {
|
||
return `Over ${windowText}, temperatures still lean cooler.`;
|
||
}
|
||
if (score >= 18) {
|
||
return `Over ${windowText}, the structure still leans warmer, but the warming pace is not strong yet.`;
|
||
}
|
||
if (score <= -18) {
|
||
return `Over ${windowText}, the structure still leans cooler, but the cooling pace is not decisive yet.`;
|
||
}
|
||
if (tempDelta >= 0.8) {
|
||
return `Over ${windowText}, temperatures still lean warmer, but confidence is limited.`;
|
||
}
|
||
if (tempDelta <= -0.8) {
|
||
return `Over ${windowText}, temperatures still lean cooler, but confidence is limited.`;
|
||
}
|
||
return `Over ${windowText}, temperatures are more likely to stay range-bound for now.`;
|
||
}
|
||
|
||
if (score >= 18 && tempDelta >= 0.5) {
|
||
return `${windowText}偏增温,后续更可能继续往上走。`;
|
||
}
|
||
if (score <= -18 && tempDelta <= -0.5) {
|
||
return `${windowText}偏降温,后续更可能继续往下走。`;
|
||
}
|
||
if (score >= 18) {
|
||
return `${windowText}仍偏增温,但增温兑现力度暂时不算强。`;
|
||
}
|
||
if (score <= -18) {
|
||
return `${windowText}仍偏降温,但降温兑现力度暂时不算强。`;
|
||
}
|
||
if (tempDelta >= 0.8) {
|
||
return `${windowText}略偏增温,但结构信号置信度有限。`;
|
||
}
|
||
if (tempDelta <= -0.8) {
|
||
return `${windowText}略偏降温,但结构信号置信度有限。`;
|
||
}
|
||
return `${windowText}更像震荡整理,短时升降温方向暂不清晰。`;
|
||
})();
|
||
const summary = (() => {
|
||
const parts: string[] = [];
|
||
|
||
if (isEnglish(locale)) {
|
||
parts.push(directionalLead);
|
||
|
||
if (lastBucket === "southerly" && firstBucket !== "southerly") {
|
||
parts.push("Low-level wind turns more southerly.");
|
||
} else if (lastBucket === "northerly" && firstBucket !== "northerly") {
|
||
parts.push("Low-level wind shifts toward a northerly regime.");
|
||
}
|
||
|
||
if (tempDelta >= 0.8) {
|
||
parts.push(`Temperature rises by ${formatDelta(tempDelta, detail.temp_symbol)}.`);
|
||
} else if (tempDelta <= -0.8) {
|
||
parts.push(`Temperature eases by ${formatDelta(tempDelta, detail.temp_symbol)}.`);
|
||
}
|
||
|
||
if (dewDelta >= 0.8) {
|
||
parts.push("Dew point is lifting, suggesting moisture transport is strengthening.");
|
||
} else if (dewDelta <= -0.8) {
|
||
parts.push("Dew point is falling, so low-level air is turning drier.");
|
||
}
|
||
|
||
if (cloudDelta >= 15) {
|
||
parts.push("Cloud cover is building.");
|
||
} else if (cloudDelta <= -15) {
|
||
parts.push("Cloud cover is easing.");
|
||
}
|
||
|
||
if (pressureDelta >= 1) {
|
||
parts.push("Pressure rebound argues for a cooler push.");
|
||
} else if (pressureDelta <= -1) {
|
||
parts.push("Pressure is softening, which is less hostile to warming.");
|
||
}
|
||
|
||
if (precipMax >= 50) {
|
||
parts.push(
|
||
primaryPrecipWindow
|
||
? `Model precipitation risk concentrates in ${precipWindowLabel}.`
|
||
: "Precipitation risk is high enough to watch for cloud/rain suppression.",
|
||
);
|
||
}
|
||
|
||
if (!parts.length) {
|
||
parts.push(`Structured trend is mixed, so the core judgement still centers on ${windowText}.`);
|
||
} else {
|
||
parts.push(`Core judgement remains focused on ${windowText}.`);
|
||
}
|
||
} else {
|
||
parts.push(directionalLead);
|
||
|
||
if (lastBucket === "southerly" && firstBucket !== "southerly") {
|
||
parts.push("低层风向更偏南,暖空气输送权重上升。");
|
||
} else if (lastBucket === "northerly" && firstBucket !== "northerly") {
|
||
parts.push("低层风向转偏北,冷空气影响权重上升。");
|
||
}
|
||
|
||
if (tempDelta >= 0.8) {
|
||
parts.push(`气温抬升 ${formatDelta(tempDelta, detail.temp_symbol)}。`);
|
||
} else if (tempDelta <= -0.8) {
|
||
parts.push(`气温回落 ${formatDelta(tempDelta, detail.temp_symbol)}。`);
|
||
}
|
||
|
||
if (dewDelta >= 0.8) {
|
||
parts.push("露点同步上升,说明暖湿输送在增强。");
|
||
} else if (dewDelta <= -0.8) {
|
||
parts.push("露点回落,低层空气在转干。");
|
||
}
|
||
|
||
if (cloudDelta >= 15) {
|
||
parts.push("云量正在增多。");
|
||
} else if (cloudDelta <= -15) {
|
||
parts.push("云量正在回落。");
|
||
}
|
||
|
||
if (pressureDelta >= 1) {
|
||
parts.push("气压回升,更偏向冷空气压入。");
|
||
} else if (pressureDelta <= -1) {
|
||
parts.push("气压走低,对增温压制减弱。");
|
||
}
|
||
|
||
if (precipMax >= 50) {
|
||
parts.push(
|
||
primaryPrecipWindow
|
||
? `模型降水窗口主要落在 ${precipWindowLabel}。`
|
||
: "降水概率已足以关注云雨压温。",
|
||
);
|
||
}
|
||
|
||
if (!parts.length) {
|
||
parts.push(`结构信号分化较大,核心仍围绕${windowText}观察。`);
|
||
} else {
|
||
parts.push(`核心判断窗口仍以${windowText}为主。`);
|
||
}
|
||
}
|
||
|
||
return parts.join(isEnglish(locale) ? " " : "");
|
||
})();
|
||
const tafContrastSummary =
|
||
tafSignal.available && dateStr === detail.local_date
|
||
? (() => {
|
||
const tafSuppression = String(
|
||
tafSignal.suppression_level || "low",
|
||
).toLowerCase();
|
||
const isCoolingBias = score <= -18;
|
||
const isWarmingBias = score >= 18;
|
||
|
||
if (tafSuppression === "low" && isCoolingBias) {
|
||
return isEnglish(locale)
|
||
? "TAF is not adding a new cloud/rain suppression signal, but the near-surface window is already leaning cooler, so the current cooling bias still comes mainly from surface structure."
|
||
: "TAF 没有新增云雨压温利空,但当前峰值窗口里的近地面结构已经偏弱,所以这次偏降温判断仍主要来自近地面信号。";
|
||
}
|
||
if (tafSuppression === "low" && isWarmingBias) {
|
||
return isEnglish(locale)
|
||
? "TAF is not adding a new cloud/rain cap, and the warmer bias still comes mainly from the surface window."
|
||
: "TAF 没有新增云雨压温约束,当前偏升温判断仍主要来自近地面窗口。";
|
||
}
|
||
if (tafSuppression === "medium" && isCoolingBias) {
|
||
return isEnglish(locale)
|
||
? "TAF is not the only driver here; it only reinforces part of the cooling-side case, while the main tilt still comes from the surface window."
|
||
: "这次偏降温不只是 TAF 在起作用;TAF 只是加强了部分冷侧判断,主方向仍来自近地面窗口。";
|
||
}
|
||
return "";
|
||
})()
|
||
: "";
|
||
const tafSummaryLineBody = [
|
||
tafPrimarySummary,
|
||
tafReferenceSummary,
|
||
tafFallbackSummary,
|
||
tafContrastSummary,
|
||
]
|
||
.filter(Boolean)
|
||
.join(isEnglish(locale) ? " " : "");
|
||
const surfaceSummaryLine = summary
|
||
? isEnglish(locale)
|
||
? `Surface: ${summary}`
|
||
: `近地面:${summary}`
|
||
: "";
|
||
const tafSummaryLine = tafSummaryLineBody
|
||
? isEnglish(locale)
|
||
? `Airport TAF: ${tafSummaryLineBody}`
|
||
: `机场 TAF:${tafSummaryLineBody}`
|
||
: "";
|
||
const summaryLines = [surfaceSummaryLine, tafSummaryLine].filter(Boolean);
|
||
const combinedSummary = summaryLines.join(isEnglish(locale) ? " " : "");
|
||
const cloudNote = (() => {
|
||
if (cloudDelta >= 15 && tempDelta >= 0.8 && dewDelta >= 0.8) {
|
||
return isEnglish(locale)
|
||
? "Clouds are increasing while temperature and dew point still rise; this usually fits ongoing warm-moist transport rather than immediate cooling."
|
||
: "云量上升时温度和露点仍在抬升,更像暖湿输送持续中,而不是立刻转凉。";
|
||
}
|
||
if (cloudDelta >= 15 && tempDelta >= 0 && lastBucket === "southerly") {
|
||
return isEnglish(locale)
|
||
? "Clouds are building without clear cooling, and the low-level wind still leans southerly; watch for warm advection to continue."
|
||
: "云量增多但未明显降温,且低层风仍偏南,需继续关注暖平流是否延续。";
|
||
}
|
||
if (cloudDelta >= 15 && tempDelta < 0 && precipMax >= 40) {
|
||
return isEnglish(locale)
|
||
? "Clouds are thickening while temperature eases and precipitation risk is elevated; cloud/rain suppression is becoming more likely."
|
||
: "云量增厚且气温回落,同时降水概率偏高,更像云雨压温开始生效。";
|
||
}
|
||
if (cloudDelta >= 15 && tempDelta < 0 && pressureDelta >= 1) {
|
||
return isEnglish(locale)
|
||
? "Clouds are increasing while temperature softens and pressure rebounds; watch for cold-air push or frontal suppression."
|
||
: "云量上升同时气温走弱、气压回升,需留意冷空气压入或锋面压温。";
|
||
}
|
||
if (cloudDelta <= -15 && tempDelta >= 0.8) {
|
||
return isEnglish(locale)
|
||
? "Cloud cover is easing while temperature rises; daytime heating efficiency is improving."
|
||
: "云量回落且温度抬升,白天增温效率在改善。";
|
||
}
|
||
return isEnglish(locale)
|
||
? "Read forecast cloud-cover increase together with temperature, dew point, wind, and precipitation; it does not override the current observed sky condition."
|
||
: "这里显示的是预测窗口内的云量增幅,需要结合温度、露点、风向和降水一起看,不能覆盖当前实况的天空状况。";
|
||
})();
|
||
const dewNote = (() => {
|
||
if (dewDelta >= 1.2 && tempDelta >= 0.8) {
|
||
return isEnglish(locale)
|
||
? "Dew point and temperature rise together, which usually supports strengthening warm-moist transport."
|
||
: "露点和温度同步抬升,更偏向暖湿输送增强。";
|
||
}
|
||
if (dewDelta >= 1.2 && precipMax >= 40) {
|
||
return isEnglish(locale)
|
||
? "Moisture is building while precipitation risk is already notable; watch for showers to cap daytime heating."
|
||
: "水汽在累积且降水风险已抬升,需关注阵雨对午后增温的压制。";
|
||
}
|
||
if (dewDelta <= -1.2 && tempDelta <= 0) {
|
||
return isEnglish(locale)
|
||
? "Drier low-level air is arriving together with softer temperature, which leans away from warm-moist support."
|
||
: "低层空气在转干且温度偏弱,暖湿支撑正在减弱。";
|
||
}
|
||
return isEnglish(locale)
|
||
? "Use dew-point change to judge whether low-level warm-moist transport is strengthening or fading."
|
||
: "露点变化主要用于判断低层暖湿输送是在增强还是减弱。";
|
||
})();
|
||
const pressureNote = (() => {
|
||
if (pressureDelta >= 1.2 && tempDelta <= -0.8) {
|
||
return isEnglish(locale)
|
||
? "Pressure rebound with cooling usually points to a cooler push or frontal suppression."
|
||
: "气压回升且温度走弱,更像冷空气压入或锋面压温。";
|
||
}
|
||
if (pressureDelta <= -1.0 && tempDelta >= 0.8) {
|
||
return isEnglish(locale)
|
||
? "Pressure is softening while temperature rises, a setup less hostile to warming."
|
||
: "气压走低同时温度抬升,对增温的压制相对减弱。";
|
||
}
|
||
return isEnglish(locale)
|
||
? "Pressure change is used as a supporting signal for cold-air push versus warming resilience."
|
||
: "气压变化更适合作为冷空气压入或增温韧性的辅助判断。";
|
||
})();
|
||
const windNote = (() => {
|
||
if (firstBucket !== lastBucket && lastBucket === "southerly") {
|
||
return isEnglish(locale)
|
||
? "Wind turns toward a southerly regime, which is more favorable for warming."
|
||
: "风向转偏南,更有利于增温。";
|
||
}
|
||
if (firstBucket !== lastBucket && lastBucket === "northerly") {
|
||
return isEnglish(locale)
|
||
? "Wind turns toward a northerly regime, which is more favorable for cooling."
|
||
: "风向转偏北,更有利于降温。";
|
||
}
|
||
if (lastBucket === "southerly") {
|
||
return isEnglish(locale)
|
||
? "Low-level flow remains southerly, so warm advection has not been disrupted."
|
||
: "低层风维持偏南,暖平流支撑尚未被破坏。";
|
||
}
|
||
if (lastBucket === "northerly") {
|
||
return isEnglish(locale)
|
||
? "Low-level flow remains northerly, so cooling-side support is still present."
|
||
: "低层风维持偏北,降温侧支撑仍在。";
|
||
}
|
||
return isEnglish(locale)
|
||
? "Wind-direction change matters most when it crosses into southerly or northerly buckets."
|
||
: "风向变化最关键的是是否跨入偏南或偏北风桶。";
|
||
})();
|
||
const precipNote = (() => {
|
||
if (precipMax >= 60) {
|
||
return isEnglish(locale)
|
||
? `${precipWindowSummary} Cloud/rain suppression can materially change the peak outcome.`
|
||
: `${precipWindowSummary} 降水风险已高到足以显著改变峰值兑现结果,需要重点防压温。`;
|
||
}
|
||
if (precipMax >= 40) {
|
||
return isEnglish(locale)
|
||
? `${precipWindowSummary} Watch whether cloud and showers interrupt daytime heating.`
|
||
: `${precipWindowSummary} 需要关注云系和阵雨是否打断白天增温。`;
|
||
}
|
||
return isEnglish(locale)
|
||
? "Precipitation risk remains limited and is used mainly as a suppression check."
|
||
: "降水风险暂时有限,主要作为压温风险校验项。";
|
||
})();
|
||
|
||
const metrics = [
|
||
{
|
||
label: isEnglish(locale) ? "Temperature delta" : "温度变化",
|
||
note: isEnglish(locale)
|
||
? `Official Open-Meteo hourly data; window: ${windowText}`
|
||
: `官方 Open-Meteo 小时数据;计算窗口:${windowText}`,
|
||
tone: tempDelta >= 0.8 ? "warm" : tempDelta <= -0.8 ? "cold" : "",
|
||
value: formatDelta(tempDelta, detail.temp_symbol),
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Dew point delta" : "露点变化",
|
||
note: dewNote,
|
||
tone: dewDelta >= 0.8 ? "warm" : dewDelta <= -0.8 ? "cold" : "",
|
||
value: formatDelta(dewDelta, detail.temp_symbol),
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Pressure delta" : "气压变化",
|
||
note: pressureNote,
|
||
tone: pressureDelta >= 1 ? "cold" : pressureDelta <= -1 ? "warm" : "",
|
||
value: formatDelta(pressureDelta, " hPa"),
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Wind-direction evolution" : "风向演变",
|
||
note: windNote,
|
||
value: `${bucketLabel(firstBucket, locale)} -> ${bucketLabel(lastBucket, locale)}`,
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Precip window" : "降水窗口",
|
||
note: precipNote,
|
||
tone: precipMax >= 50 ? "cold" : "",
|
||
value: primaryPrecipWindow
|
||
? `${precipWindowLabel} · ${Math.round(precipMax)}%`
|
||
: `${Math.round(precipMax)}%`,
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Forecast cloud-cover delta" : "预测云量增幅",
|
||
note: cloudNote,
|
||
tone:
|
||
cloudDelta >= 15 && tempDelta >= 0
|
||
? "warm"
|
||
: cloudDelta >= 15 && tempDelta < 0
|
||
? "cold"
|
||
: "",
|
||
value: formatDelta(cloudDelta, "%"),
|
||
},
|
||
];
|
||
|
||
if (backendNotes.length) {
|
||
const normalizedSummary = backendSummary.trim();
|
||
const alignedNotes = backendNotes.filter(
|
||
(note) => String(note || "").trim() !== normalizedSummary,
|
||
);
|
||
alignedNotes.slice(0, metrics.length).forEach((note, index) => {
|
||
if (!note) return;
|
||
metrics[index] = {
|
||
...metrics[index],
|
||
note,
|
||
};
|
||
});
|
||
}
|
||
|
||
return {
|
||
confidence,
|
||
label,
|
||
metrics,
|
||
upperAirMetrics,
|
||
upperAirSummary,
|
||
precipOverlapLevel,
|
||
precipWindowLabel,
|
||
precipMax,
|
||
score,
|
||
summary: combinedSummary || backendSummary || summary,
|
||
summaryLines,
|
||
weatherGovPeriods,
|
||
};
|
||
}
|
||
|
||
export function getFutureModalView(
|
||
detail: CityDetail,
|
||
dateStr: string,
|
||
locale: Locale = "zh-CN",
|
||
) {
|
||
const forecastEntry =
|
||
detail.forecast?.daily?.find((item) => item.date === dateStr) || null;
|
||
const dailyModel = detail.multi_model_daily?.[dateStr] || {};
|
||
const probabilities = dailyModel.probabilities || [];
|
||
const totalProbability = probabilities.reduce((sum, item) => {
|
||
const probability = Number(item.probability);
|
||
return Number.isFinite(probability) ? sum + probability : sum;
|
||
}, 0);
|
||
const weightedProbability = probabilities.reduce((sum, item) => {
|
||
const value = Number(item.value);
|
||
const probability = Number(item.probability);
|
||
if (!Number.isFinite(value) || !Number.isFinite(probability)) {
|
||
return sum;
|
||
}
|
||
return sum + value * probability;
|
||
}, 0);
|
||
const mu = totalProbability > 0 ? weightedProbability / totalProbability : null;
|
||
const deb = dailyModel.deb?.prediction ?? forecastEntry?.max_temp ?? null;
|
||
|
||
return {
|
||
deb,
|
||
forecastEntry,
|
||
front: computeFrontTrendSignal(detail, dateStr, locale),
|
||
models: dailyModel.models || {},
|
||
mu: Number.isFinite(Number(mu)) ? Number(mu) : null,
|
||
probabilities,
|
||
slice: getFutureSlice(detail, dateStr),
|
||
};
|
||
}
|
||
|
||
export function getShortTermNowcastLines(
|
||
detail: CityDetail,
|
||
dateStr: string,
|
||
locale: Locale = "zh-CN",
|
||
) {
|
||
const slice = getFutureSlice(detail, dateStr);
|
||
if (dateStr !== detail.local_date) {
|
||
const afternoon = slice.filter((point) => {
|
||
const hour = Number.parseInt(String(point.label).split(":")[0], 10);
|
||
return Number.isFinite(hour) && hour >= 12 && hour <= 18;
|
||
});
|
||
const target = afternoon.length ? afternoon : slice;
|
||
if (!target.length) {
|
||
return [
|
||
[isEnglish(locale) ? "Target date" : "目标日期", dateStr],
|
||
[
|
||
isEnglish(locale) ? "Peak window" : "峰值窗口",
|
||
isEnglish(locale)
|
||
? "No sufficient hourly forecast data for target-day peak-window diagnostics."
|
||
: "暂无足够的小时级 forecast 数据,无法生成目标日午后峰值窗口判断。",
|
||
],
|
||
] as const;
|
||
}
|
||
|
||
const maxIndex = target.reduce((bestIndex, point, index, array) => {
|
||
const temp = Number(point.temp);
|
||
const bestTemp = Number(array[bestIndex]?.temp);
|
||
if (!Number.isFinite(temp)) return bestIndex;
|
||
if (!Number.isFinite(bestTemp) || temp > bestTemp) return index;
|
||
return bestIndex;
|
||
}, 0);
|
||
|
||
const peakSlice = target.slice(
|
||
Math.max(0, maxIndex - 1),
|
||
Math.min(target.length, maxIndex + 2),
|
||
);
|
||
const start = peakSlice[0];
|
||
const end = peakSlice[peakSlice.length - 1];
|
||
const peakPoint = target[maxIndex] || end;
|
||
const startTemp = Number(start.temp);
|
||
const endTemp = Number(end.temp);
|
||
const startDew = Number(start.dewPoint);
|
||
const endDew = Number(end.dewPoint);
|
||
const startPressure = Number(start.pressure);
|
||
const endPressure = Number(end.pressure);
|
||
const precipValues = peakSlice
|
||
.map((point) => Number(point.precipProb))
|
||
.filter(Number.isFinite);
|
||
const cloudValues = peakSlice
|
||
.map((point) => Number(point.cloudCover))
|
||
.filter(Number.isFinite);
|
||
const maxPrecip = precipValues.length ? Math.max(...precipValues) : 0;
|
||
const maxCloud = cloudValues.length ? Math.max(...cloudValues) : 0;
|
||
|
||
return [
|
||
[isEnglish(locale) ? "Target date" : "目标日期", dateStr],
|
||
[
|
||
isEnglish(locale) ? "Peak window" : "峰值窗口",
|
||
isEnglish(locale)
|
||
? `${start.label} - ${end.label} (prefer 12:00-18:00)`
|
||
: `${start.label} - ${end.label}(优先取 12:00-18:00)`,
|
||
],
|
||
[
|
||
isEnglish(locale) ? "Peak estimate" : "峰值预估",
|
||
`${Number.isFinite(Number(peakPoint.temp)) ? Number(peakPoint.temp).toFixed(1) : "--"}${detail.temp_symbol} @ ${peakPoint.label || "--"}`,
|
||
],
|
||
[
|
||
isEnglish(locale) ? "Window temperature" : "窗口温度",
|
||
`${Number.isFinite(startTemp) ? startTemp.toFixed(1) : "--"}${detail.temp_symbol} -> ${Number.isFinite(endTemp) ? endTemp.toFixed(1) : "--"}${detail.temp_symbol} (${formatDelta(endTemp - startTemp, detail.temp_symbol)})`,
|
||
],
|
||
[
|
||
isEnglish(locale) ? "Dew-point delta" : "露点变化",
|
||
isEnglish(locale)
|
||
? `${formatDelta(endDew - startDew, detail.temp_symbol)} for diagnosing warm/wet transport in afternoon.`
|
||
: `${formatDelta(endDew - startDew, detail.temp_symbol)},用于判断午后暖湿输送是否增强。`,
|
||
],
|
||
[
|
||
isEnglish(locale) ? "Wind shift" : "风向演变",
|
||
isEnglish(locale)
|
||
? `${bucketLabel(trendBucketFromDir(start.windDir), locale)} -> ${bucketLabel(trendBucketFromDir(end.windDir), locale)} around peak window.`
|
||
: `${bucketLabel(trendBucketFromDir(start.windDir), locale)} -> ${bucketLabel(trendBucketFromDir(end.windDir), locale)},关注峰值前后是否转南风或回摆北风。`,
|
||
],
|
||
[
|
||
isEnglish(locale) ? "Pressure delta" : "气压变化",
|
||
isEnglish(locale)
|
||
? `${formatDelta(endPressure - startPressure, " hPa")} (higher pressure usually favors cold-air push).`
|
||
: `${formatDelta(endPressure - startPressure, " hPa")},上升更偏向冷空气压入。`,
|
||
],
|
||
[
|
||
isEnglish(locale) ? "Precip / cloud" : "降水 / 云量",
|
||
isEnglish(locale)
|
||
? `${Math.round(maxPrecip)}% / ${Math.round(maxCloud)}% for cloud-suppression judgement around peak hours.`
|
||
: `${Math.round(maxPrecip)}% / ${Math.round(maxCloud)}%,用于判断峰值时段是否受云系压制。`,
|
||
],
|
||
] as const;
|
||
}
|
||
|
||
const recent = Array.isArray(detail.metar_recent_obs)
|
||
? detail.metar_recent_obs.slice(-4)
|
||
: [];
|
||
const nearby = Array.isArray(detail.official_nearby) && detail.official_nearby.length
|
||
? detail.official_nearby
|
||
: Array.isArray(detail.mgm_nearby)
|
||
? detail.mgm_nearby
|
||
: [];
|
||
const nearbySource = String(detail.nearby_source || "").toLowerCase();
|
||
const sourceLabel =
|
||
nearbySource === "mgm" || isTurkishMgmCity(detail)
|
||
? isEnglish(locale)
|
||
? "MGM nearby stations"
|
||
: "MGM 周边站"
|
||
: nearbySource === "official_cluster"
|
||
? isEnglish(locale)
|
||
? "Official nearby stations"
|
||
: "官方周边站"
|
||
: isEnglish(locale)
|
||
? "METAR nearby stations"
|
||
: "METAR 周边站";
|
||
const currentTemp = Number(detail.current?.temp);
|
||
const recentTemps = recent
|
||
.map((point) => Number(point.temp))
|
||
.filter((value) => Number.isFinite(value));
|
||
const baseline = recentTemps.length ? recentTemps[0] : currentTemp;
|
||
const shortDelta =
|
||
Number.isFinite(currentTemp) && Number.isFinite(baseline)
|
||
? currentTemp - baseline
|
||
: 0;
|
||
let nearbyLead: { diff: number; name: string; temp: number; syncText: string } | null = null;
|
||
|
||
for (const station of nearby) {
|
||
const temp = Number(station.temp);
|
||
if (station.usable_for_intraday === false) continue;
|
||
if (!Number.isFinite(temp) || !Number.isFinite(currentTemp)) continue;
|
||
const diff = temp - currentTemp;
|
||
const syncStatus = String(station.sync_status || "").toLowerCase();
|
||
const syncDelta = Number(station.time_delta_vs_anchor_minutes);
|
||
const syncText =
|
||
syncStatus === "synced"
|
||
? isEnglish(locale)
|
||
? "time-synced"
|
||
: "时间同步"
|
||
: syncStatus === "near_realtime" || syncStatus === "lagged"
|
||
? Number.isFinite(syncDelta)
|
||
? isEnglish(locale)
|
||
? `${Math.round(syncDelta)} min offset`
|
||
: `时间差 ${Math.round(syncDelta)} 分钟`
|
||
: isEnglish(locale)
|
||
? "not fully synchronized"
|
||
: "非完全同步"
|
||
: syncStatus === "unknown"
|
||
? isEnglish(locale)
|
||
? "timing unverified"
|
||
: "时间待校验"
|
||
: "";
|
||
if (!nearbyLead || Math.abs(diff) > Math.abs(nearbyLead.diff)) {
|
||
nearbyLead = {
|
||
diff,
|
||
name:
|
||
station.name ||
|
||
station.icao ||
|
||
(isEnglish(locale) ? "Nearby station" : "周边站"),
|
||
temp,
|
||
syncText,
|
||
};
|
||
}
|
||
}
|
||
const usableNearbyCount = nearby.filter((station) => station.usable_for_intraday !== false).length;
|
||
|
||
const rows: Array<readonly [string, string]> = [
|
||
[
|
||
isEnglish(locale) ? "Primary station" : "当前主站",
|
||
`${detail.current?.temp ?? "--"}${detail.temp_symbol} @ ${detail.current?.obs_time || "--"}`,
|
||
],
|
||
[
|
||
isEnglish(locale) ? "Raw METAR" : "原始 METAR",
|
||
detail.current?.raw_metar || (isEnglish(locale) ? "N/A" : "暂无"),
|
||
],
|
||
[
|
||
isEnglish(locale) ? "Next 0-2h" : "近 0-2 小时",
|
||
isEnglish(locale)
|
||
? `${formatDelta(shortDelta, detail.temp_symbol)} based on latest METAR sequence short-term momentum.`
|
||
: `${formatDelta(shortDelta, detail.temp_symbol)},依据最近 METAR 序列判断短时动量。`,
|
||
],
|
||
[
|
||
sourceLabel,
|
||
isEnglish(locale)
|
||
? `${usableNearbyCount}/${nearby.length} stations usable for the nearby scan; station timestamps may differ.`
|
||
: `${usableNearbyCount}/${nearby.length} 个站点可参与邻近监控;周边站观测时间可能不同步。`,
|
||
],
|
||
];
|
||
|
||
if (nearbyLead) {
|
||
const tone = isEnglish(locale)
|
||
? nearbyLead.diff > 0
|
||
? "warmer"
|
||
: nearbyLead.diff < 0
|
||
? "cooler"
|
||
: "flat"
|
||
: nearbyLead.diff > 0
|
||
? "偏暖"
|
||
: nearbyLead.diff < 0
|
||
? "偏冷"
|
||
: "持平";
|
||
rows.push([
|
||
isEnglish(locale) ? "Leading station" : "领先站",
|
||
isEnglish(locale)
|
||
? `${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol}, relative to primary station ${formatDelta(nearbyLead.diff, detail.temp_symbol)} (${tone})${nearbyLead.syncText ? `; ${nearbyLead.syncText}` : ""}.`
|
||
: `${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol},相对主站 ${formatDelta(nearbyLead.diff, detail.temp_symbol)}(${tone})${nearbyLead.syncText ? `;${nearbyLead.syncText}` : ""}。`,
|
||
]);
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
export function getHistorySummary(
|
||
history: HistoryPoint[],
|
||
cityLocalDate?: string | null,
|
||
) {
|
||
const toFinite = (value: unknown): number | null => {
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) ? numeric : null;
|
||
};
|
||
const isExcludedModel = (name: string) =>
|
||
String(name || "").toLowerCase().includes("meteoblue");
|
||
|
||
const cutoff = new Date();
|
||
cutoff.setHours(0, 0, 0, 0);
|
||
cutoff.setDate(cutoff.getDate() - 14);
|
||
|
||
const recentData = history.filter((row) => {
|
||
if (!row?.date) return false;
|
||
const rowDate = new Date(`${row.date}T00:00:00`);
|
||
return !Number.isNaN(rowDate.getTime()) && rowDate >= cutoff;
|
||
});
|
||
|
||
const settledData = recentData.filter((row) => {
|
||
if (!row?.date) return false;
|
||
return cityLocalDate
|
||
? row.date < cityLocalDate
|
||
: row.date < new Date().toISOString().slice(0, 10);
|
||
});
|
||
const comparableSettledData = settledData.filter((row) => {
|
||
const actual = toFinite(row.actual);
|
||
const deb = toFinite(row.deb);
|
||
return actual != null && deb != null;
|
||
});
|
||
|
||
let hits = 0;
|
||
const debErrors: number[] = [];
|
||
const modelErrors: Record<string, number[]> = {};
|
||
|
||
comparableSettledData.forEach((row) => {
|
||
const actual = toFinite(row.actual);
|
||
const deb = toFinite(row.deb);
|
||
if (actual == null || deb == null) return;
|
||
debErrors.push(Math.abs(actual - deb));
|
||
if (wuRound(actual) === wuRound(deb)) {
|
||
hits += 1;
|
||
}
|
||
|
||
const forecasts = row.forecasts || {};
|
||
Object.entries(forecasts).forEach(([modelName, modelValue]) => {
|
||
if (isExcludedModel(modelName)) return;
|
||
const mv = toFinite(modelValue);
|
||
if (actual == null || mv == null) return;
|
||
if (!modelErrors[modelName]) {
|
||
modelErrors[modelName] = [];
|
||
}
|
||
modelErrors[modelName].push(Math.abs(actual - mv));
|
||
});
|
||
});
|
||
|
||
const modelMaeList = Object.entries(modelErrors)
|
||
.map(([name, errors]) => ({
|
||
mae:
|
||
errors.length > 0
|
||
? errors.reduce((sum, value) => sum + value, 0) / errors.length
|
||
: Number.POSITIVE_INFINITY,
|
||
model: name,
|
||
sampleCount: errors.length,
|
||
}))
|
||
.filter((row) => Number.isFinite(row.mae) && row.sampleCount > 0)
|
||
.sort((a, b) => a.mae - b.mae);
|
||
|
||
const primaryModelMaeList = modelMaeList.filter((row) => row.sampleCount >= 2);
|
||
const bestModel = (primaryModelMaeList[0] || modelMaeList[0]) ?? null;
|
||
const bestModelName = bestModel?.model || null;
|
||
const bestModelMae = bestModel ? Number(bestModel.mae.toFixed(1)) : null;
|
||
const bestModelSeries = recentData.map((row) =>
|
||
bestModelName ? toFinite(row.forecasts?.[bestModelName]) : null,
|
||
);
|
||
|
||
let debWinDaysVsBest = 0;
|
||
let debVsBestComparableDays = 0;
|
||
if (bestModelName) {
|
||
comparableSettledData.forEach((row) => {
|
||
const actual = toFinite(row.actual);
|
||
const deb = toFinite(row.deb);
|
||
const bestModelVal = toFinite(row.forecasts?.[bestModelName]);
|
||
if (actual == null || deb == null || bestModelVal == null) return;
|
||
debVsBestComparableDays += 1;
|
||
if (Math.abs(deb - actual) <= Math.abs(bestModelVal - actual)) {
|
||
debWinDaysVsBest += 1;
|
||
}
|
||
});
|
||
}
|
||
|
||
const mgmSettledCount = settledData.reduce((count, row) => {
|
||
return toFinite(row.mgm) != null ? count + 1 : count;
|
||
}, 0);
|
||
const mgmSeriesComplete =
|
||
settledData.length >= 2 && mgmSettledCount === settledData.length;
|
||
const mgmSeries = mgmSeriesComplete
|
||
? recentData.map((row) => row.mgm ?? null)
|
||
: recentData.map(() => null);
|
||
|
||
return {
|
||
dates: recentData.map((row) => row.date),
|
||
debMae: debErrors.length
|
||
? Number(
|
||
(
|
||
debErrors.reduce((sum, value) => sum + value, 0) / debErrors.length
|
||
).toFixed(1),
|
||
)
|
||
: null,
|
||
debs: recentData.map((row) => row.deb),
|
||
bestModelName,
|
||
bestModelMae,
|
||
bestModelSeries,
|
||
modelMaeRanks: modelMaeList.map((row) => ({
|
||
model: row.model,
|
||
mae: Number(row.mae.toFixed(1)),
|
||
sampleCount: row.sampleCount,
|
||
})),
|
||
debWinDaysVsBest,
|
||
debVsBestComparableDays,
|
||
debWinRateVsBest:
|
||
debVsBestComparableDays > 0
|
||
? Number(((debWinDaysVsBest / debVsBestComparableDays) * 100).toFixed(0))
|
||
: null,
|
||
hitRate: debErrors.length
|
||
? Number(((hits / debErrors.length) * 100).toFixed(0))
|
||
: null,
|
||
mgmSeriesComplete,
|
||
mgms: mgmSeries,
|
||
recentData,
|
||
settledCount: comparableSettledData.length,
|
||
actuals: recentData.map((row) => row.actual),
|
||
};
|
||
}
|
||
|
||
function toFiniteNumber(value: unknown): number | null {
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) ? numeric : null;
|
||
}
|
||
|
||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||
return value && typeof value === "object"
|
||
? (value as Record<string, unknown>)
|
||
: null;
|
||
}
|
||
|
||
function firstFiniteNumber(values: unknown[]) {
|
||
for (const value of values) {
|
||
const numeric = toFiniteNumber(value);
|
||
if (numeric != null) return numeric;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function firstNonEmptyString(values: unknown[]) {
|
||
for (const value of values) {
|
||
const text = String(value ?? "").trim();
|
||
if (text) return text;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function formatObservationUpdate(value: unknown, locale: Locale) {
|
||
const raw = String(value ?? "").trim();
|
||
if (!raw) return null;
|
||
|
||
const looksDated =
|
||
/^\d{4}-\d{2}-\d{2}/.test(raw) ||
|
||
raw.includes("T") ||
|
||
/[+-]\d{2}:?\d{2}$/.test(raw);
|
||
if (looksDated) {
|
||
const parsed = new Date(raw);
|
||
if (!Number.isNaN(parsed.getTime())) {
|
||
return new Intl.DateTimeFormat(isEnglish(locale) ? "en-US" : "zh-CN", {
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
hour12: false,
|
||
minute: "2-digit",
|
||
month: "2-digit",
|
||
})
|
||
.format(parsed)
|
||
.replace(",", "");
|
||
}
|
||
}
|
||
|
||
return normalizeHm(raw) || raw;
|
||
}
|
||
|
||
function localObservationTimeCandidate(value: unknown) {
|
||
const raw = String(value ?? "").trim();
|
||
if (!raw || raw.includes("T") || /^\d{4}-\d{2}-\d{2}/.test(raw)) {
|
||
return "";
|
||
}
|
||
return normalizeHm(raw) || raw;
|
||
}
|
||
|
||
function getOfficialObservationCandidates(detail: CityDetail) {
|
||
const officialNearby = Array.isArray(detail.official_nearby)
|
||
? detail.official_nearby
|
||
: [];
|
||
const mgmNearby = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : [];
|
||
const officialAnchor =
|
||
officialNearby.find(
|
||
(station) => station.is_settlement_anchor || station.is_airport_station,
|
||
) || officialNearby[0];
|
||
|
||
return {
|
||
centerStation: detail.center_station_candidate,
|
||
mgmNearby,
|
||
officialAnchor,
|
||
officialNearby,
|
||
};
|
||
}
|
||
|
||
function getObservedTemperatureProfile(detail: CityDetail, locale: Locale) {
|
||
const { mgmNearby } = getOfficialObservationCandidates(detail);
|
||
const currentSource = String(
|
||
detail.current?.settlement_source ||
|
||
detail.current?.settlement_source_label ||
|
||
"",
|
||
)
|
||
.trim()
|
||
.toLowerCase();
|
||
const isNmcCurrent = currentSource === "nmc" || currentSource.includes("nmc");
|
||
const isNmcAirport = String(detail.airport_primary?.source_label || "")
|
||
.trim()
|
||
.toLowerCase()
|
||
.includes("nmc");
|
||
const candidates = [
|
||
{
|
||
sourceLabel: detail.airport_current?.source_label || "METAR",
|
||
temp: detail.airport_current?.temp,
|
||
},
|
||
{
|
||
sourceLabel: detail.airport_primary?.source_label || "METAR",
|
||
temp: isNmcAirport ? null : detail.airport_primary?.temp,
|
||
},
|
||
{
|
||
sourceLabel: detail.current?.settlement_source_label,
|
||
temp: isNmcCurrent ? null : detail.current?.temp,
|
||
},
|
||
{
|
||
sourceLabel: mgmNearby[0]?.source_label,
|
||
temp: mgmNearby[0]?.temp,
|
||
},
|
||
];
|
||
const selected = candidates.find((candidate) =>
|
||
Number.isFinite(Number(candidate.temp)),
|
||
);
|
||
|
||
if (!selected) {
|
||
return isEnglish(locale) ? "Unavailable" : "未提供";
|
||
}
|
||
|
||
const observedTemp = Number(selected.temp);
|
||
const sourceLabel = firstNonEmptyString([
|
||
selected.sourceLabel,
|
||
getObservationSourceTag(detail),
|
||
]);
|
||
const rounded =
|
||
Math.abs(observedTemp - Math.round(observedTemp)) < 0.05
|
||
? String(Math.round(observedTemp))
|
||
: observedTemp.toFixed(1);
|
||
|
||
return `${rounded}${detail.temp_symbol || "°C"}${
|
||
sourceLabel ? ` · ${sourceLabel}` : ""
|
||
}`;
|
||
}
|
||
|
||
function getObservationUpdateProfile(detail: CityDetail, locale: Locale) {
|
||
const { mgmNearby } = getOfficialObservationCandidates(detail);
|
||
const mgmFirstRecord = asRecord(mgmNearby[0]);
|
||
const currentSource = String(
|
||
detail.current?.settlement_source ||
|
||
detail.current?.settlement_source_label ||
|
||
"",
|
||
)
|
||
.trim()
|
||
.toLowerCase();
|
||
const isNmcCurrent = currentSource === "nmc" || currentSource.includes("nmc");
|
||
const rawValue = firstNonEmptyString([
|
||
isNmcCurrent ? "" : detail.current?.obs_time,
|
||
localObservationTimeCandidate(detail.airport_primary?.obs_time),
|
||
localObservationTimeCandidate(detail.airport_current?.obs_time),
|
||
localObservationTimeCandidate(mgmFirstRecord?.obs_time),
|
||
localObservationTimeCandidate(mgmFirstRecord?.time),
|
||
detail.airport_primary?.report_time,
|
||
detail.airport_current?.report_time,
|
||
isNmcCurrent ? "" : detail.current?.report_time,
|
||
mgmFirstRecord?.report_time,
|
||
detail.updated_at,
|
||
]);
|
||
|
||
return (
|
||
formatObservationUpdate(rawValue, locale) ||
|
||
(isEnglish(locale) ? "Unavailable" : "未提供")
|
||
);
|
||
}
|
||
|
||
export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN") {
|
||
const risk = detail.risk || {};
|
||
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
|
||
const nearbySource = String(detail.nearby_source || "").trim().toLowerCase();
|
||
const sourceCode = getObservationSourceCode(detail);
|
||
const isOfficialSource =
|
||
sourceCode === "hko" ||
|
||
sourceCode === "cwa" ||
|
||
sourceCode === "noaa";
|
||
|
||
const sourceDisplay = (() => {
|
||
if (sourceCode === "hko") {
|
||
return isEnglish(locale)
|
||
? "Hong Kong Observatory (HKO)"
|
||
: "香港天文台 (HKO)";
|
||
}
|
||
if (sourceCode === "cwa") {
|
||
return isEnglish(locale)
|
||
? "Central Weather Administration (CWA)"
|
||
: "交通部中央气象署 (CWA)";
|
||
}
|
||
if (sourceCode === "noaa") {
|
||
const noaaCode = getNoaaStationCode(detail);
|
||
const noaaName = getNoaaStationName(detail);
|
||
return isEnglish(locale)
|
||
? `${noaaName}${noaaCode ? ` (${noaaCode})` : ""}`
|
||
: `${noaaName}${noaaCode ? `(${noaaCode})` : ""}`;
|
||
}
|
||
if (sourceCode === "wunderground") {
|
||
const icao = String(detail.risk?.icao || detail.current?.station_code || "")
|
||
.trim()
|
||
.toUpperCase();
|
||
const stationName = String(
|
||
detail.current?.station_name || detail.risk?.airport || "",
|
||
).trim();
|
||
return `${stationName || icao || "Airport"}${icao ? ` (${icao} METAR)` : " METAR"}`;
|
||
}
|
||
const stationName = String(
|
||
detail.current?.station_name || detail.risk?.airport || "",
|
||
).trim();
|
||
const stationCode = String(
|
||
detail.current?.station_code || detail.risk?.icao || "",
|
||
).trim();
|
||
if (stationName) {
|
||
return `${stationName}${stationCode ? ` (${stationCode})` : ""}`;
|
||
}
|
||
const tag = getObservationSourceTag(detail);
|
||
if (sourceCode === "mgm") {
|
||
return isEnglish(locale) ? `MGM (${tag})` : `MGM (${tag})`;
|
||
}
|
||
if (risk.airport && risk.icao) return `${risk.airport} (${risk.icao})`;
|
||
if (risk.airport) return String(risk.airport);
|
||
return isEnglish(locale) ? "No profile" : "暂无档案";
|
||
})();
|
||
|
||
const rows = [
|
||
{
|
||
label: isOfficialSource
|
||
? isEnglish(locale)
|
||
? "Settlement station"
|
||
: "结算站点"
|
||
: isEnglish(locale)
|
||
? "Settlement airport"
|
||
: "结算机场",
|
||
value: sourceDisplay,
|
||
},
|
||
{
|
||
label: isOfficialSource
|
||
? isEnglish(locale)
|
||
? "Reference distance"
|
||
: "参考距离"
|
||
: isEnglish(locale)
|
||
? "Station distance"
|
||
: "站点距离",
|
||
value:
|
||
risk.distance_km != null && Number.isFinite(Number(risk.distance_km))
|
||
? `${risk.distance_km} km`
|
||
: isEnglish(locale)
|
||
? "Not marked"
|
||
: "未标注",
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Observed temp" : "实测温度",
|
||
value: getObservedTemperatureProfile(detail, locale),
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Observation update" : "观测更新",
|
||
value: getObservationUpdateProfile(detail, locale),
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Nearby stations" : "周边站点",
|
||
value:
|
||
nearbyCount > 0
|
||
? isEnglish(locale)
|
||
? `${nearbyCount} participating stations`
|
||
: `${nearbyCount} 个参与监控`
|
||
: isEnglish(locale)
|
||
? "No nearby stations"
|
||
: "暂无周边站",
|
||
},
|
||
];
|
||
|
||
if (nearbyCount > 0) {
|
||
rows.push({
|
||
label: isEnglish(locale) ? "Nearby source" : "周边站来源",
|
||
value:
|
||
nearbySource === "kma"
|
||
? isEnglish(locale)
|
||
? "KMA official stations"
|
||
: "KMA 官方站"
|
||
: nearbySource === "official_cluster"
|
||
? isEnglish(locale)
|
||
? "Official station cluster"
|
||
: "官方站簇"
|
||
: nearbySource === "mgm"
|
||
? "MGM"
|
||
: isEnglish(locale)
|
||
? "Airport / METAR network"
|
||
: "机场 / METAR 网络",
|
||
});
|
||
}
|
||
|
||
if (nearbySource === "kma" && detail.airport_current?.temp != null) {
|
||
const airportLabel =
|
||
String(
|
||
detail.airport_current.station_label ||
|
||
detail.airport_current.station_code ||
|
||
detail.risk?.airport ||
|
||
"",
|
||
).trim() ||
|
||
(isEnglish(locale) ? "Airport station" : "机场主站");
|
||
const airportObsTime =
|
||
String(detail.airport_current.obs_time || "").trim() ||
|
||
(isEnglish(locale) ? "pending" : "待更新");
|
||
const airportHigh =
|
||
detail.airport_current.max_so_far != null
|
||
? `${detail.airport_current.max_so_far}${detail.temp_symbol || ""}${
|
||
detail.airport_current.max_temp_time
|
||
? ` @ ${detail.airport_current.max_temp_time}`
|
||
: ""
|
||
}`
|
||
: isEnglish(locale)
|
||
? "Unavailable"
|
||
: "未提供";
|
||
|
||
rows.push({
|
||
label: isEnglish(locale) ? "Airport reference" : "机场主站参考",
|
||
value: `${airportLabel}: ${detail.airport_current.temp}${
|
||
detail.temp_symbol || ""
|
||
} @ ${airportObsTime}`,
|
||
});
|
||
rows.push({
|
||
label: isEnglish(locale) ? "Airport high" : "机场目前最高温",
|
||
value: airportHigh,
|
||
});
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
export function getSettlementRiskNarrative(
|
||
detail: CityDetail,
|
||
locale: Locale = "zh-CN",
|
||
) {
|
||
const risk = detail.risk || {};
|
||
const sourceCode = getObservationSourceCode(detail);
|
||
const stationTerm =
|
||
sourceCode === "hko" ||
|
||
sourceCode === "cwa" ||
|
||
sourceCode === "noaa"
|
||
? isEnglish(locale)
|
||
? "settlement reference station"
|
||
: "结算参考站"
|
||
: isEnglish(locale)
|
||
? "settlement airport"
|
||
: "结算机场";
|
||
const lines: string[] = [];
|
||
|
||
if (risk.warning) {
|
||
lines.push(
|
||
isEnglish(locale)
|
||
? `Current key risk: ${risk.warning}`
|
||
: `当前主要风险是:${risk.warning}`,
|
||
);
|
||
}
|
||
|
||
if (risk.distance_km != null) {
|
||
if (risk.distance_km >= 60) {
|
||
lines.push(
|
||
isEnglish(locale)
|
||
? `The ${stationTerm} is far from urban core; market feel and settlement value may diverge significantly.`
|
||
: `${stationTerm}与城市核心区域距离偏大,盘面温度与结算值可能出现明显背离。`,
|
||
);
|
||
} else if (risk.distance_km >= 25) {
|
||
lines.push(
|
||
isEnglish(locale)
|
||
? `The ${stationTerm} has material distance from downtown; peak/overnight rhythm should prioritize the settlement station.`
|
||
: `${stationTerm}与城区存在可感知距离,午后峰值和夜间降温节奏需要优先看结算站。`,
|
||
);
|
||
} else {
|
||
lines.push(
|
||
isEnglish(locale)
|
||
? `The ${stationTerm} is close enough; city feel and settlement temperature are usually more synchronized.`
|
||
: `${stationTerm}距离较近,城市体感与结算温度通常更同步。`,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (isTurkishMgmCity(detail)) {
|
||
lines.push(
|
||
isEnglish(locale)
|
||
? "For Turkish MGM-supported cities, focus on the airport station plus MGM nearby-station linkage, not urban sensation alone."
|
||
: "对接入 MGM 的土耳其城市,需要重点看机场站与 MGM 周边站联动,不能只看城区体感。",
|
||
);
|
||
}
|
||
|
||
if (detail.current?.obs_age_min != null) {
|
||
if (detail.current.obs_age_min >= 45) {
|
||
lines.push(
|
||
isEnglish(locale)
|
||
? `Current METAR is ${detail.current.obs_age_min} minutes old. Blend nearby stations for nowcast instead of single-station snapshot.`
|
||
: `当前 METAR 已有 ${detail.current.obs_age_min} 分钟时滞,临近判断要结合周边站而不是只看主站快照。`,
|
||
);
|
||
} else {
|
||
lines.push(
|
||
isEnglish(locale)
|
||
? "Primary station observation is fresh enough; short-term judgement can anchor on it."
|
||
: "当前主站观测较新,短时判断可以把主站温度作为主要锚点。",
|
||
);
|
||
}
|
||
}
|
||
|
||
return lines;
|
||
}
|
||
|
||
export function getClimateDrivers(detail: CityDetail, locale: Locale = "zh-CN") {
|
||
const drivers: Array<{ label: string; text: string }> = [];
|
||
const lat = Math.abs(Number(detail.lat));
|
||
const nearbyCount = Array.isArray(detail.mgm_nearby)
|
||
? detail.mgm_nearby.length
|
||
: 0;
|
||
const distanceKm = Number(detail.risk?.distance_km);
|
||
|
||
if (lat >= 50) {
|
||
drivers.push({
|
||
label: isEnglish(locale) ? "High-latitude cold air" : "高纬冷空气",
|
||
text: isEnglish(locale)
|
||
? "At higher latitude, temperature rhythm is more affected by cold-air surges, trough passage, and seasonal radiation angle."
|
||
: "该城市位于较高纬度,温度变化更容易受到冷空气南下、短波槽和日照角度变化影响。",
|
||
});
|
||
} else if (lat >= 35) {
|
||
drivers.push({
|
||
label: isEnglish(locale) ? "Mid-latitude westerlies" : "中纬西风带",
|
||
text: isEnglish(locale)
|
||
? "Temperature shifts are often controlled by frontal transitions rather than pure daytime radiation."
|
||
: "该城市主要受中纬西风带和锋面活动控制,升降温常来自气团切换,而不是单一日照变化。",
|
||
});
|
||
} else if (lat >= 20) {
|
||
drivers.push({
|
||
label: isEnglish(locale) ? "Subtropical highs" : "副热带高压",
|
||
text: isEnglish(locale)
|
||
? "Subtropical ridge, clear-sky radiation and low-level warm advection often dominate warming efficiency."
|
||
: "该城市更容易受副热带高压、晴空辐射和低层暖平流影响,午后增温能力通常更强。",
|
||
});
|
||
} else {
|
||
drivers.push({
|
||
label: isEnglish(locale) ? "Tropical moisture & convection" : "热带水汽与对流",
|
||
text: isEnglish(locale)
|
||
? "Temperature and feels-like are often modulated by moisture transport, cloud convection and showers."
|
||
: "该城市偏热带环境,温度与体感常受水汽输送、云对流和阵雨触发影响。",
|
||
});
|
||
}
|
||
|
||
drivers.push({
|
||
label: isEnglish(locale) ? "Dry-wet boundary layer" : "干湿边界层",
|
||
text: isEnglish(locale)
|
||
? "Boundary-layer humidity controls daytime warming efficiency; dry boundary warms faster, wet boundary is more cloud/precip-sensitive."
|
||
: "低层干湿状态会决定午后升温效率。干空气通常升温更快,湿空气更容易受云量和降水过程抑制。",
|
||
});
|
||
|
||
drivers.push({
|
||
label: isEnglish(locale) ? "Advection transport" : "平流输送",
|
||
text: isEnglish(locale)
|
||
? "Short-term trend is usually driven by low-level air-mass transport. Persistent wind origin tends to sustain thermal direction."
|
||
: "短时趋势常由低层气团输送控制。若风向持续来自同一侧,温度通常更容易沿该方向延续。",
|
||
});
|
||
|
||
if (Number.isFinite(distanceKm) && distanceKm >= 25) {
|
||
drivers.push({
|
||
label: isEnglish(locale) ? "Station representativeness" : "站点代表性",
|
||
text: isEnglish(locale)
|
||
? "When settlement station is not near city core, perceived temperature and settlement value may diverge."
|
||
: "结算站与城市核心区存在一定距离时,体感温度和结算温度可能分离,评估时应优先以结算站观测为准。",
|
||
});
|
||
}
|
||
|
||
if (nearbyCount >= 4) {
|
||
drivers.push({
|
||
label: isEnglish(locale) ? "Local heterogeneity" : "局地差异",
|
||
text: isEnglish(locale)
|
||
? "More nearby stations suggest terrain/urban-heat heterogeneity; settlement station and downtown sensation should be evaluated separately."
|
||
: "周边可用站点较多,说明地形、城区热岛或下垫面差异可能明显,结算站与城区体感需要分开评估。",
|
||
});
|
||
}
|
||
|
||
return drivers;
|
||
}
|