1176 lines
40 KiB
TypeScript
1176 lines
40 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 }
|
||
> = {
|
||
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: "雾" },
|
||
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 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 };
|
||
}
|
||
|
||
export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
|
||
const current = detail.current || {};
|
||
const parts: string[] = [];
|
||
|
||
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(`✈️ METAR ${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 (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 || [];
|
||
|
||
if (!times.length) return null;
|
||
|
||
const currentHour = detail.local_time
|
||
? `${detail.local_time.split(":")[0]}:00`
|
||
: null;
|
||
const currentIndex = currentHour ? times.indexOf(currentHour) : -1;
|
||
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 metarPoints = new Array(times.length).fill(null);
|
||
const metarSource = detail.metar_today_obs?.length
|
||
? detail.metar_today_obs
|
||
: detail.trend?.recent || [];
|
||
|
||
metarSource.forEach((item) => {
|
||
const parts = String(item.time || "").split(":");
|
||
let hour = Number.parseInt(parts[0], 10);
|
||
const minute = Number.parseInt(parts[1] || "0", 10);
|
||
if (Number.isNaN(hour)) return;
|
||
if (minute >= 30) hour = (hour + 1) % 24;
|
||
const key = `${String(hour).padStart(2, "0")}:00`;
|
||
const index = times.indexOf(key);
|
||
if (index >= 0 && metarPoints[index] === null) {
|
||
metarPoints[index] = item.temp ?? null;
|
||
}
|
||
});
|
||
|
||
const mgmPoints = new Array(times.length).fill(null);
|
||
if (detail.mgm?.temp != null && detail.mgm?.time) {
|
||
const match = detail.mgm.time.match(/T?(\d{2}):(\d{2})/);
|
||
if (match) {
|
||
let hour = Number.parseInt(match[1], 10);
|
||
const minute = Number.parseInt(match[2], 10);
|
||
if (minute >= 30) hour = (hour + 1) % 24;
|
||
const key = `${String(hour).padStart(2, "0")}:00`;
|
||
const index = times.indexOf(key);
|
||
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 match = String(item.time || "").match(/T?(\d{2}):(\d{2})/);
|
||
if (!match) return;
|
||
const key = `${match[1]}:00`;
|
||
const index = times.indexOf(key);
|
||
if (index >= 0) {
|
||
mgmHourlyPoints[index] = item.temp ?? null;
|
||
hasMgmHourly = true;
|
||
}
|
||
});
|
||
|
||
const allValues = [
|
||
...debTemps.filter((value) => value != null),
|
||
...metarPoints.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 legendParts: string[] = [];
|
||
if (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) {
|
||
const recentText = [...detail.trend.recent]
|
||
.slice(0, 4)
|
||
.reverse()
|
||
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
|
||
.join(" -> ");
|
||
legendParts.push(`METAR: ${recentText}`);
|
||
}
|
||
|
||
return {
|
||
datasets: {
|
||
debFuture,
|
||
debPast,
|
||
hasMgmHourly,
|
||
metarPoints,
|
||
mgmHourlyPoints,
|
||
mgmPoints,
|
||
offset,
|
||
temps,
|
||
},
|
||
legendText: legendParts.join(" | "),
|
||
max,
|
||
min,
|
||
times,
|
||
};
|
||
}
|
||
|
||
export function getProbabilityView(detail: CityDetail, targetDate?: string | null) {
|
||
const date = targetDate || detail.local_date;
|
||
if (date === detail.local_date) {
|
||
return {
|
||
mu: detail.probabilities?.mu ?? null,
|
||
probabilities: detail.probabilities?.distribution || [],
|
||
};
|
||
}
|
||
|
||
const daily = detail.multi_model_daily?.[date];
|
||
return {
|
||
mu: daily?.deb?.prediction ?? null,
|
||
probabilities: daily?.probabilities || [],
|
||
};
|
||
}
|
||
|
||
export function getModelView(detail: CityDetail, targetDate?: string | null) {
|
||
const toFiniteNumber = (value: unknown): number | null => {
|
||
if (value === null || value === undefined || value === "") return null;
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) ? numeric : null;
|
||
};
|
||
|
||
const pickMeteoblueForDate = (dateStr: string) => {
|
||
const meteoblue = detail.source_forecasts?.meteoblue;
|
||
if (!meteoblue) return null;
|
||
|
||
const dates = detail.forecast?.daily?.map((item) => item.date) || [];
|
||
const index = dates.findIndex((date) => date === dateStr);
|
||
const todayHigh = toFiniteNumber(meteoblue.today_high);
|
||
|
||
// Today must always use Meteoblue daily max (today_high) first.
|
||
if (dateStr === detail.local_date && todayHigh != null) {
|
||
return todayHigh;
|
||
}
|
||
|
||
const dailyHighs = Array.isArray(meteoblue.daily_highs)
|
||
? meteoblue.daily_highs
|
||
: [];
|
||
if (index >= 0) {
|
||
const byDailyHigh = toFiniteNumber(dailyHighs[index]);
|
||
if (byDailyHigh != null) return byDailyHigh;
|
||
}
|
||
|
||
if (index === 0 && todayHigh != null) return todayHigh;
|
||
|
||
return null;
|
||
};
|
||
|
||
const withMeteoblue = (
|
||
models: Record<string, number | null>,
|
||
dateStr: string,
|
||
) => {
|
||
const nextModels = { ...models };
|
||
const existing = toFiniteNumber(nextModels.Meteoblue);
|
||
if (existing == null) {
|
||
const meteoblueVal = pickMeteoblueForDate(dateStr);
|
||
if (meteoblueVal != null) {
|
||
nextModels.Meteoblue = meteoblueVal;
|
||
}
|
||
} else {
|
||
nextModels.Meteoblue = existing;
|
||
}
|
||
return nextModels;
|
||
};
|
||
|
||
const date = targetDate || detail.local_date;
|
||
const daily = detail.multi_model_daily?.[date];
|
||
if (daily) {
|
||
return {
|
||
deb: daily.deb?.prediction ?? null,
|
||
models: withMeteoblue(daily.models || {}, date),
|
||
};
|
||
}
|
||
|
||
return {
|
||
deb: detail.deb?.prediction ?? null,
|
||
models: withMeteoblue(detail.multi_model || {}, date),
|
||
};
|
||
}
|
||
|
||
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 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;
|
||
}
|
||
|
||
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 || ""] || "风向不明"
|
||
);
|
||
}
|
||
|
||
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 slice = getFutureSlice(detail, dateStr);
|
||
const currentTemp = Number(detail.current?.temp);
|
||
const currentDew = Number(detail.current?.dewpoint);
|
||
|
||
if (!slice.length) {
|
||
return {
|
||
confidence: "low",
|
||
label: isEnglish(locale) ? "Monitoring" : "监控中",
|
||
metrics: [] as Array<{
|
||
label: string;
|
||
note: string;
|
||
tone?: string;
|
||
value: string;
|
||
}>,
|
||
precipMax: 0,
|
||
score: 0,
|
||
summary: isEnglish(locale)
|
||
? "Insufficient 48h structured data. Keep baseline monitoring."
|
||
: "未来 48 小时结构化数据不足,暂时只保留基础监控。",
|
||
weatherGovPeriods: [] as ReturnType<typeof getForecastTextForDate>,
|
||
};
|
||
}
|
||
|
||
const first = slice[0];
|
||
const last = slice[slice.length - 1];
|
||
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 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)
|
||
? "Warm advection / warm-front tendency"
|
||
: "暖平流 / 暖锋倾向";
|
||
const coldLabel = isEnglish(locale)
|
||
? "Cold advection / cold-front tendency"
|
||
: "冷平流 / 冷锋倾向";
|
||
const monitorLabel = isEnglish(locale) ? "Monitoring" : "监控中";
|
||
const label = score >= 18 ? warmLabel : score <= -18 ? coldLabel : monitorLabel;
|
||
const confidence =
|
||
Math.abs(score) >= 45 ? "high" : Math.abs(score) >= 22 ? "medium" : "low";
|
||
|
||
return {
|
||
confidence,
|
||
label,
|
||
metrics: [
|
||
{
|
||
label: isEnglish(locale) ? "Temperature delta" : "温度变化",
|
||
note: isEnglish(locale)
|
||
? "Open-Meteo upcoming hourly temperature change"
|
||
: "Open-Meteo 未来小时温度变化",
|
||
tone: tempDelta >= 0.8 ? "warm" : tempDelta <= -0.8 ? "cold" : "",
|
||
value: formatDelta(tempDelta, detail.temp_symbol),
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Dew point delta" : "露点变化",
|
||
note: isEnglish(locale)
|
||
? "Rising dew point often supports warm/wet advection"
|
||
: "露点上升更偏向暖湿平流",
|
||
tone: dewDelta >= 0.8 ? "warm" : dewDelta <= -0.8 ? "cold" : "",
|
||
value: formatDelta(dewDelta, detail.temp_symbol),
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Pressure delta" : "气压变化",
|
||
note: isEnglish(locale)
|
||
? "Pressure rebound usually implies cold-air push"
|
||
: "气压回升更偏向冷空气压入",
|
||
tone: pressureDelta >= 1 ? "cold" : pressureDelta <= -1 ? "warm" : "",
|
||
value: formatDelta(pressureDelta, " hPa"),
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Wind-direction evolution" : "风向演变",
|
||
note: isEnglish(locale)
|
||
? "Focus on switch to southerly or northerly flow"
|
||
: "关注是否转南风或转北风",
|
||
value: `${bucketLabel(firstBucket, locale)} -> ${bucketLabel(lastBucket, locale)}`,
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Precip probability" : "降水概率",
|
||
note: "weather.gov / Open-Meteo",
|
||
tone: precipMax >= 50 ? "cold" : "",
|
||
value: `${Math.round(precipMax)}%`,
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Cloud-cover delta" : "云量变化",
|
||
note: isEnglish(locale)
|
||
? "Cloud increase without cooling may imply warm advection"
|
||
: "云量抬升但未降温,常见于暖平流前段",
|
||
tone:
|
||
cloudDelta >= 15 && tempDelta >= 0
|
||
? "warm"
|
||
: cloudDelta >= 15 && tempDelta < 0
|
||
? "cold"
|
||
: "",
|
||
value: formatDelta(cloudDelta, "%"),
|
||
},
|
||
],
|
||
precipMax,
|
||
score,
|
||
summary:
|
||
label === warmLabel
|
||
? isEnglish(locale)
|
||
? "Southerly flow strengthens with rising dew point and temperature. Next 6-48h leans warm advection."
|
||
: "风向更偏南 / 西南,露点与温度整体抬升,未来 6-48 小时偏向暖平流。"
|
||
: label === coldLabel
|
||
? isEnglish(locale)
|
||
? "Temperature declines with pressure rebound and/or northerly shift. Next 6-48h leans cold-front suppression."
|
||
: "温度下滑、气压回升或风向转北,未来 6-48 小时更像冷锋或冷平流压制。"
|
||
: detail.name !== "ankara" && Boolean(detail.source_forecasts?.meteoblue)
|
||
? isEnglish(locale)
|
||
? "Structured trend layer mainly uses weather.gov, Open-Meteo and Meteoblue for 6-48h warm/cold flow judgement."
|
||
: "结构化来源以 weather.gov、Open-Meteo、Meteoblue 为主,用于判断未来 6-48 小时冷暖平流趋势。"
|
||
: isEnglish(locale)
|
||
? "Structured trend layer mainly uses weather.gov and Open-Meteo for 6-48h warm/cold flow judgement."
|
||
: "结构化来源以 weather.gov 和 Open-Meteo 为主,用于判断未来 6-48 小时冷暖平流趋势。",
|
||
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.mgm_nearby) ? detail.mgm_nearby : [];
|
||
const sourceLabel =
|
||
detail.name === "ankara"
|
||
? isEnglish(locale)
|
||
? "MGM nearby stations"
|
||
: "MGM 周边站"
|
||
: 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 } | null = null;
|
||
|
||
for (const station of nearby) {
|
||
const temp = Number(station.temp);
|
||
if (!Number.isFinite(temp) || !Number.isFinite(currentTemp)) continue;
|
||
const diff = temp - currentTemp;
|
||
if (!nearbyLead || Math.abs(diff) > Math.abs(nearbyLead.diff)) {
|
||
nearbyLead = {
|
||
diff,
|
||
name:
|
||
station.name ||
|
||
station.icao ||
|
||
(isEnglish(locale) ? "Nearby station" : "周边站"),
|
||
temp,
|
||
};
|
||
}
|
||
}
|
||
|
||
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)
|
||
? `${nearby.length} stations joined the nearby scan.`
|
||
: `${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.name} ${nearbyLead.temp}${detail.temp_symbol},相对主站 ${formatDelta(nearbyLead.diff, detail.temp_symbol)}(${tone})。`,
|
||
]);
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
export function getHistorySummary(
|
||
history: HistoryPoint[],
|
||
cityLocalDate?: string | null,
|
||
) {
|
||
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);
|
||
});
|
||
|
||
let hits = 0;
|
||
const debErrors: number[] = [];
|
||
settledData.forEach((row) => {
|
||
if (row.actual != null && row.deb != null) {
|
||
debErrors.push(Math.abs(row.actual - row.deb));
|
||
if (wuRound(row.actual) === wuRound(row.deb)) {
|
||
hits += 1;
|
||
}
|
||
}
|
||
});
|
||
|
||
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),
|
||
hitRate: debErrors.length
|
||
? Number(((hits / debErrors.length) * 100).toFixed(0))
|
||
: null,
|
||
mgms: recentData.map((row) => row.mgm ?? null),
|
||
recentData,
|
||
settledCount: settledData.length,
|
||
actuals: recentData.map((row) => row.actual),
|
||
};
|
||
}
|
||
|
||
export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN") {
|
||
const risk = detail.risk || {};
|
||
const current = detail.current || {};
|
||
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
|
||
|
||
return [
|
||
{
|
||
label: isEnglish(locale) ? "Settlement airport" : "结算机场",
|
||
value:
|
||
risk.airport && risk.icao
|
||
? `${risk.airport} (${risk.icao})`
|
||
: isEnglish(locale)
|
||
? "No profile"
|
||
: "暂无档案",
|
||
},
|
||
{
|
||
label: 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) ? "Observation update" : "观测更新",
|
||
value:
|
||
current.obs_time ||
|
||
detail.updated_at ||
|
||
(isEnglish(locale) ? "Unavailable" : "未提供"),
|
||
},
|
||
{
|
||
label: isEnglish(locale) ? "Nearby stations" : "周边站点",
|
||
value:
|
||
nearbyCount > 0
|
||
? isEnglish(locale)
|
||
? `${nearbyCount} participating stations`
|
||
: `${nearbyCount} 个参与监控`
|
||
: isEnglish(locale)
|
||
? "No nearby stations"
|
||
: "暂无周边站",
|
||
},
|
||
];
|
||
}
|
||
|
||
export function getSettlementRiskNarrative(
|
||
detail: CityDetail,
|
||
locale: Locale = "zh-CN",
|
||
) {
|
||
const risk = detail.risk || {};
|
||
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)
|
||
? "Settlement airport is far from urban core; market feel and settlement value may diverge significantly."
|
||
: "结算机场与城市核心区域距离偏大,盘面温度与结算值可能出现明显背离。",
|
||
);
|
||
} else if (risk.distance_km >= 25) {
|
||
lines.push(
|
||
isEnglish(locale)
|
||
? "Settlement airport has material distance from downtown; peak/overnight rhythm should prioritize airport station."
|
||
: "结算机场与城区存在可感知距离,午后峰值和夜间降温节奏需要优先看机场站。",
|
||
);
|
||
} else {
|
||
lines.push(
|
||
isEnglish(locale)
|
||
? "Settlement airport is close enough; city feel and settlement temperature are usually more synchronized."
|
||
: "结算机场距离较近,城市体感与结算温度通常更同步。",
|
||
);
|
||
}
|
||
}
|
||
|
||
if (detail.name === "ankara") {
|
||
lines.push(
|
||
isEnglish(locale)
|
||
? "For Ankara, focus on LTAC / Esenboğa plus MGM nearby-station linkage, not urban sensation alone."
|
||
: "Ankara 需要重点看 LTAC / Esenboğa 与 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;
|
||
}
|