The dashboard had several oversized orchestration, component, and CSS files that made product-copy changes and mobile/performance work risky. This refactor preserves behavior while splitting scan terminal CSS, opportunity helpers, future forecast panels, history/detail charts, and probability/model sections into smaller ownership boundaries. Constraint: No user-visible version bump because this batch is architecture and performance cleanup, not a release announcement. Rejected: Rewrite dashboard state management in the same batch | too broad for a safe upload after CSS and component splitting. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep new component/CSS boundaries instead of moving product copy back into the large dashboard files. Tested: npm run build; npm run test:business; git diff --check Not-tested: Browser visual smoke test after push
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||
import { formatTemperatureValue } from "@/lib/temperature-utils";
|
||
|
||
export function decodeRawMetarCloud(rawMetar?: string | null, locale = "zh-CN") {
|
||
const raw = String(rawMetar || "").toUpperCase();
|
||
const matches = Array.from(raw.matchAll(/\b(FEW|SCT|BKN|OVC)(\d{3})?\b/g));
|
||
if (!matches.length) return "";
|
||
const coverText: Record<string, { zh: string; en: string }> = {
|
||
FEW: { zh: "少云", en: "few" },
|
||
SCT: { zh: "散云", en: "scattered" },
|
||
BKN: { zh: "多云", en: "broken" },
|
||
OVC: { zh: "阴天", en: "overcast" },
|
||
};
|
||
return matches
|
||
.slice(0, 3)
|
||
.map((match) => {
|
||
const cover = coverText[match[1]] || { zh: match[1], en: match[1] };
|
||
const base = match[2] ? `${Number(match[2]) * 100}ft` : "";
|
||
return locale === "en-US"
|
||
? [cover.en, base].filter(Boolean).join(" ")
|
||
: [cover.zh, base].filter(Boolean).join(" ");
|
||
})
|
||
.join(locale === "en-US" ? ", " : "、");
|
||
}
|
||
|
||
export function decodeRawMetarVisibility(rawMetar?: string | null) {
|
||
const raw = String(rawMetar || "").toUpperCase();
|
||
if (/\b9999\b/.test(raw)) return "10km+";
|
||
const meterMatch = raw.match(/\b(\d{4})\b/);
|
||
if (meterMatch) return `${Number(meterMatch[1]) / 1000}km`;
|
||
return "";
|
||
}
|
||
|
||
export function decodeMetarWeatherToken(token?: string | null, locale = "zh-CN") {
|
||
const raw = String(token || "").trim().toUpperCase();
|
||
if (!raw) return "";
|
||
const isEn = locale === "en-US";
|
||
const intensity = raw.startsWith("-")
|
||
? isEn
|
||
? "light "
|
||
: "轻"
|
||
: raw.startsWith("+")
|
||
? isEn
|
||
? "heavy "
|
||
: "强"
|
||
: "";
|
||
const cleaned = raw.replace(/^[+-]/, "");
|
||
const descriptors: Record<string, { zh: string; en: string }> = {
|
||
VC: { zh: "附近", en: "nearby " },
|
||
SH: { zh: "阵性", en: "showery " },
|
||
TS: { zh: "雷暴性", en: "thunderstorm " },
|
||
FZ: { zh: "冻", en: "freezing " },
|
||
BL: { zh: "吹扬", en: "blowing " },
|
||
DR: { zh: "低吹", en: "drifting " },
|
||
MI: { zh: "浅层", en: "shallow " },
|
||
BC: { zh: "碎片状", en: "patches of " },
|
||
PR: { zh: "部分", en: "partial " },
|
||
};
|
||
const phenomena: Record<string, { zh: string; en: string }> = {
|
||
DZ: { zh: "毛毛雨", en: "drizzle" },
|
||
RA: { zh: "雨", en: "rain" },
|
||
SN: { zh: "雪", en: "snow" },
|
||
SG: { zh: "米雪", en: "snow grains" },
|
||
IC: { zh: "冰晶", en: "ice crystals" },
|
||
PL: { zh: "冰粒", en: "ice pellets" },
|
||
GR: { zh: "冰雹", en: "hail" },
|
||
GS: { zh: "小冰雹", en: "small hail" },
|
||
UP: { zh: "未知降水", en: "unknown precipitation" },
|
||
BR: { zh: "薄雾", en: "mist" },
|
||
FG: { zh: "雾", en: "fog" },
|
||
FU: { zh: "烟", en: "smoke" },
|
||
VA: { zh: "火山灰", en: "volcanic ash" },
|
||
DU: { zh: "浮尘", en: "dust" },
|
||
SA: { zh: "沙", en: "sand" },
|
||
HZ: { zh: "霾", en: "haze" },
|
||
PY: { zh: "喷雾", en: "spray" },
|
||
PO: { zh: "尘卷风", en: "dust whirls" },
|
||
SQ: { zh: "飑", en: "squall" },
|
||
FC: { zh: "漏斗云", en: "funnel cloud" },
|
||
SS: { zh: "沙暴", en: "sandstorm" },
|
||
DS: { zh: "尘暴", en: "duststorm" },
|
||
};
|
||
const descriptorText = Object.entries(descriptors)
|
||
.filter(([code]) => cleaned.includes(code))
|
||
.map(([, text]) => (isEn ? text.en : text.zh))
|
||
.join("");
|
||
const phenomenonText = Object.entries(phenomena)
|
||
.filter(([code]) => cleaned.includes(code))
|
||
.map(([, text]) => (isEn ? text.en : text.zh))
|
||
.join(isEn ? " / " : "、");
|
||
if (!phenomenonText) return "";
|
||
return `${intensity}${descriptorText}${phenomenonText}`;
|
||
}
|
||
|
||
export function decodeRawMetarWeather(rawMetar?: string | null, locale = "zh-CN") {
|
||
const raw = String(rawMetar || "").toUpperCase();
|
||
const matches = Array.from(
|
||
raw.matchAll(/\b([+-]?(?:VC)?(?:MI|PR|BC|DR|BL|SH|TS|FZ)?(?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS))\b/g),
|
||
);
|
||
return Array.from(
|
||
new Set(
|
||
matches
|
||
.map((match) => decodeMetarWeatherToken(match[1], locale))
|
||
.filter(Boolean),
|
||
),
|
||
).join(locale === "en-US" ? ", " : "、");
|
||
}
|
||
|
||
export function getAirportWeatherInputs(row: ScanOpportunityRow, detail: CityDetail | null) {
|
||
const context = row.metar_context || {};
|
||
const airport: Partial<NonNullable<CityDetail["airport_current"]>> =
|
||
detail?.airport_current || {};
|
||
const rawMetar = String(context.airport_raw_metar || airport.raw_metar || "").trim();
|
||
return {
|
||
cloud: String(context.airport_cloud_desc || airport.cloud_desc || "").trim(),
|
||
rawMetar,
|
||
visibility:
|
||
context.airport_visibility_mi != null && Number.isFinite(Number(context.airport_visibility_mi))
|
||
? Number(context.airport_visibility_mi)
|
||
: airport.visibility_mi != null && Number.isFinite(Number(airport.visibility_mi))
|
||
? Number(airport.visibility_mi)
|
||
: null,
|
||
weather: String(context.airport_wx_desc || airport.wx_desc || "").trim(),
|
||
windSpeed:
|
||
context.airport_wind_speed_kt != null && Number.isFinite(Number(context.airport_wind_speed_kt))
|
||
? Number(context.airport_wind_speed_kt)
|
||
: airport.wind_speed_kt != null && Number.isFinite(Number(airport.wind_speed_kt))
|
||
? Number(airport.wind_speed_kt)
|
||
: null,
|
||
};
|
||
}
|
||
|
||
export function formatAirportWeatherRead(
|
||
row: ScanOpportunityRow,
|
||
detail: CityDetail | null,
|
||
locale: string,
|
||
) {
|
||
const isEn = locale === "en-US";
|
||
const inputs = getAirportWeatherInputs(row, detail);
|
||
const decodedCloud = inputs.cloud || decodeRawMetarCloud(inputs.rawMetar, locale);
|
||
const decodedWeather =
|
||
decodeMetarWeatherToken(inputs.weather, locale) ||
|
||
inputs.weather ||
|
||
decodeRawMetarWeather(inputs.rawMetar, locale);
|
||
const visibilityText =
|
||
inputs.visibility != null ? `${inputs.visibility.toFixed(1)}mi` : decodeRawMetarVisibility(inputs.rawMetar);
|
||
const cloudRaw = `${inputs.cloud} ${inputs.rawMetar}`.toUpperCase();
|
||
const weatherRaw = `${inputs.weather} ${inputs.rawMetar}`.toUpperCase();
|
||
const suppressors: string[] = [];
|
||
const supporters: string[] = [];
|
||
|
||
if (/(RA|DZ|SN|TS|SH|FG|BR|HZ|OVC|BKN)/.test(weatherRaw) || /(OVC|BKN)/.test(cloudRaw)) {
|
||
suppressors.push(
|
||
isEn
|
||
? "cloud, precipitation or restricted visibility can suppress solar heating"
|
||
: "云雨、薄雾或低能见度会压制太阳辐射升温",
|
||
);
|
||
}
|
||
if (inputs.visibility != null && inputs.visibility < 6) {
|
||
suppressors.push(
|
||
isEn
|
||
? `visibility is only ${visibilityText}, so the airport path may warm more slowly`
|
||
: `能见度仅 ${visibilityText},机场路径可能升温偏慢`,
|
||
);
|
||
}
|
||
if (/(FEW|SCT)/.test(cloudRaw) && !/(RA|DZ|SN|TS|FG|BR|HZ|OVC|BKN)/.test(weatherRaw)) {
|
||
supporters.push(
|
||
isEn
|
||
? "few or scattered clouds do not block the heating path materially"
|
||
: "少云或散云对日间升温压制不明显",
|
||
);
|
||
}
|
||
if (inputs.windSpeed != null && inputs.windSpeed >= 15) {
|
||
suppressors.push(
|
||
isEn
|
||
? "stronger wind mixing can change the airport temperature path"
|
||
: "风速偏大,边界层混合可能改写机场温度路径",
|
||
);
|
||
} else if (inputs.windSpeed != null && inputs.windSpeed <= 5 && !suppressors.length) {
|
||
supporters.push(
|
||
isEn
|
||
? "light wind leaves the temperature path mainly driven by local sunshine"
|
||
: "风速较弱,温度路径更取决于本地日照",
|
||
);
|
||
}
|
||
|
||
const descriptors = [
|
||
decodedWeather ? (isEn ? `weather ${decodedWeather}` : `天气 ${decodedWeather}`) : null,
|
||
decodedCloud ? (isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`) : null,
|
||
visibilityText ? (isEn ? `visibility ${visibilityText}` : `能见度 ${visibilityText}`) : null,
|
||
].filter(Boolean);
|
||
const read = suppressors[0] || supporters[0];
|
||
if (!descriptors.length && !read) return null;
|
||
const prefix = isEn ? "Airport weather read" : "机场气象解读";
|
||
const evidence = descriptors.length ? `${descriptors.join(isEn ? ", " : ",")};` : "";
|
||
return `${prefix}:${evidence}${read || (isEn ? "no clear weather suppression signal yet" : "暂未看到明确天气压温信号")}。`;
|
||
}
|
||
|
||
export function formatAirportReportRead(
|
||
row: ScanOpportunityRow,
|
||
detail: CityDetail | null,
|
||
locale: string,
|
||
tempSymbol?: string | null,
|
||
) {
|
||
const isEn = locale === "en-US";
|
||
const context = row.metar_context || {};
|
||
const airport: Partial<NonNullable<CityDetail["airport_current"]>> =
|
||
detail?.airport_current || {};
|
||
const station =
|
||
context.station ||
|
||
detail?.risk?.icao ||
|
||
airport.station_code ||
|
||
null;
|
||
const obsTime =
|
||
context.airport_obs_time ||
|
||
context.last_time ||
|
||
airport.obs_time ||
|
||
row.metar_status?.last_observation_time ||
|
||
null;
|
||
const temp =
|
||
context.airport_current_temp != null && Number.isFinite(Number(context.airport_current_temp))
|
||
? Number(context.airport_current_temp)
|
||
: airport.temp != null && Number.isFinite(Number(airport.temp))
|
||
? Number(airport.temp)
|
||
: null;
|
||
const windSpeed =
|
||
context.airport_wind_speed_kt != null && Number.isFinite(Number(context.airport_wind_speed_kt))
|
||
? Number(context.airport_wind_speed_kt)
|
||
: airport.wind_speed_kt != null && Number.isFinite(Number(airport.wind_speed_kt))
|
||
? Number(airport.wind_speed_kt)
|
||
: null;
|
||
const windDir =
|
||
context.airport_wind_dir != null && Number.isFinite(Number(context.airport_wind_dir))
|
||
? Number(context.airport_wind_dir)
|
||
: airport.wind_dir != null && Number.isFinite(Number(airport.wind_dir))
|
||
? Number(airport.wind_dir)
|
||
: null;
|
||
const cloud = String(context.airport_cloud_desc || airport.cloud_desc || "").trim();
|
||
const weather = String(context.airport_wx_desc || airport.wx_desc || "").trim();
|
||
const rawMetar = String(context.airport_raw_metar || airport.raw_metar || "").trim();
|
||
const decodedCloud = cloud || decodeRawMetarCloud(rawMetar, locale);
|
||
const decodedWeather =
|
||
decodeMetarWeatherToken(weather, locale) ||
|
||
weather ||
|
||
decodeRawMetarWeather(rawMetar, locale);
|
||
const visibility =
|
||
context.airport_visibility_mi != null && Number.isFinite(Number(context.airport_visibility_mi))
|
||
? Number(context.airport_visibility_mi)
|
||
: airport.visibility_mi != null && Number.isFinite(Number(airport.visibility_mi))
|
||
? Number(airport.visibility_mi)
|
||
: null;
|
||
const decodedVisibility = visibility != null ? `${visibility.toFixed(1)}mi` : decodeRawMetarVisibility(rawMetar);
|
||
|
||
const parts: string[] = [];
|
||
if (temp != null) parts.push(formatTemperatureValue(temp, tempSymbol, { digits: 1 }));
|
||
if (windSpeed != null) {
|
||
parts.push(
|
||
windDir != null
|
||
? isEn
|
||
? `wind ${Math.round(windDir)}°/${Math.round(windSpeed)}kt`
|
||
: `风 ${Math.round(windDir)}°/${Math.round(windSpeed)}kt`
|
||
: isEn
|
||
? `wind ${Math.round(windSpeed)}kt`
|
||
: `风 ${Math.round(windSpeed)}kt`,
|
||
);
|
||
}
|
||
if (decodedCloud) parts.push(isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`);
|
||
if (decodedWeather) parts.push(isEn ? `weather ${decodedWeather}` : `天气 ${decodedWeather}`);
|
||
if (decodedVisibility) parts.push(isEn ? `visibility ${decodedVisibility}` : `能见度 ${decodedVisibility}`);
|
||
if (!parts.length) return null;
|
||
const prefix = isEn ? "Latest airport METAR read" : "最新机场报文解读";
|
||
const head = [station, obsTime].filter(Boolean).join(" ");
|
||
return `${prefix}${head ? ` ${head}` : ""}:${parts.join(",")}。`;
|
||
}
|