Refine homepage focus panel

This commit is contained in:
2569718930@qq.com
2026-04-23 00:57:49 +08:00
parent 94f1a64522
commit e766728c5f
4 changed files with 597 additions and 18 deletions
@@ -5,6 +5,7 @@ import clsx from "clsx";
import { Clock } from "lucide-react";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import { CityListItem, DeviationMonitor } from "@/lib/dashboard-types";
type RiskGroupKey = "high" | "medium" | "low" | "other";
@@ -211,6 +212,11 @@ export function CitySidebar() {
const detail = store.cityDetailsByName[city.name];
const summary = store.citySummariesByName[city.name];
const snapshot = detail || summary;
const localizedCityName = getLocalizedCityName(
city.name,
snapshot?.display_name || city.display_name,
locale,
);
const isActive = store.selectedCity === city.name;
const tempSymbol = snapshot?.temp_symbol || "°C";
const currentTempText =
@@ -258,7 +264,7 @@ export function CitySidebar() {
<div className="city-item-main">
<span className={clsx("risk-dot", performanceTier)} />
<span className="city-name-text">
{city.display_name}
{localizedCityName}
</span>
<span
className={clsx(
@@ -990,6 +990,25 @@
height: 76px;
}
.root :global(.home-weather-icon .sun),
.root :global(.home-weather-icon .mist),
.root :global(.home-weather-icon .wind),
.root :global(.home-weather-icon .bolt) {
position: absolute;
}
.root :global(.home-weather-icon .sun) {
top: 8px;
left: 6px;
width: 26px;
height: 26px;
border-radius: 999px;
background: radial-gradient(circle, #fde68a 0%, #f59e0b 70%, #f97316 100%);
box-shadow:
0 0 0 7px rgba(251, 191, 36, 0.12),
0 0 22px rgba(245, 158, 11, 0.26);
}
.root :global(.home-weather-icon .cloud) {
position: absolute;
border-radius: 999px;
@@ -1033,6 +1052,130 @@
left: 67px;
}
.root :global(.home-weather-icon .mist) {
left: 20px;
width: 46px;
height: 2px;
border-radius: 999px;
background: rgba(191, 219, 254, 0.85);
box-shadow: 0 0 10px rgba(148, 163, 184, 0.18);
}
.root :global(.home-weather-icon .mist-a) {
top: 58px;
}
.root :global(.home-weather-icon .mist-b) {
top: 64px;
left: 28px;
width: 38px;
}
.root :global(.home-weather-icon .wind) {
left: 20px;
width: 44px;
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(125, 211, 252, 0), rgba(125, 211, 252, 0.92));
}
.root :global(.home-weather-icon .wind::after) {
content: "";
position: absolute;
right: -3px;
top: -1px;
width: 9px;
height: 4px;
border-top: 2px solid rgba(125, 211, 252, 0.92);
border-right: 2px solid rgba(125, 211, 252, 0.92);
border-radius: 0 8px 0 0;
}
.root :global(.home-weather-icon .wind-a) {
top: 28px;
}
.root :global(.home-weather-icon .wind-b) {
top: 42px;
left: 30px;
width: 34px;
}
.root :global(.home-weather-icon .bolt) {
top: 44px;
left: 20px;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 2px solid transparent;
border-top: 18px solid #fbbf24;
transform: skewX(-16deg);
filter: drop-shadow(0 0 12px rgba(251, 191, 36, 0.32));
}
.root :global(.home-weather-icon .bolt::after) {
content: "";
position: absolute;
left: -5px;
top: -4px;
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 2px solid transparent;
border-top: 12px solid #fde047;
transform: translateX(5px) translateY(8px);
}
.root :global(.home-weather-icon.weather-clear .cloud),
.root :global(.home-weather-icon.weather-clear .rain),
.root :global(.home-weather-icon.weather-clear .mist),
.root :global(.home-weather-icon.weather-clear .wind),
.root :global(.home-weather-icon.weather-clear .bolt) {
display: none;
}
.root :global(.home-weather-icon.weather-partly .rain),
.root :global(.home-weather-icon.weather-partly .mist),
.root :global(.home-weather-icon.weather-partly .wind),
.root :global(.home-weather-icon.weather-partly .bolt) {
display: none;
}
.root :global(.home-weather-icon.weather-cloudy .sun),
.root :global(.home-weather-icon.weather-cloudy .rain),
.root :global(.home-weather-icon.weather-cloudy .mist),
.root :global(.home-weather-icon.weather-cloudy .wind),
.root :global(.home-weather-icon.weather-cloudy .bolt) {
display: none;
}
.root :global(.home-weather-icon.weather-rain .sun),
.root :global(.home-weather-icon.weather-rain .mist),
.root :global(.home-weather-icon.weather-rain .wind),
.root :global(.home-weather-icon.weather-rain .bolt) {
display: none;
}
.root :global(.home-weather-icon.weather-storm .sun),
.root :global(.home-weather-icon.weather-storm .mist),
.root :global(.home-weather-icon.weather-storm .wind) {
display: none;
}
.root :global(.home-weather-icon.weather-mist .sun),
.root :global(.home-weather-icon.weather-mist .rain),
.root :global(.home-weather-icon.weather-mist .wind),
.root :global(.home-weather-icon.weather-mist .bolt) {
display: none;
}
.root :global(.home-weather-icon.weather-wind .sun),
.root :global(.home-weather-icon.weather-wind .rain),
.root :global(.home-weather-icon.weather-wind .mist),
.root :global(.home-weather-icon.weather-wind .bolt) {
display: none;
}
.root :global(.home-max-so-far) {
grid-column: 2;
color: rgba(203, 213, 225, 0.86);
@@ -1125,6 +1268,64 @@
color: rgba(226, 232, 240, 0.94);
}
.root :global(.home-card-section.intraday) {
border-color: rgba(45, 212, 191, 0.24);
}
.root :global(.home-intraday-chart) {
position: relative;
padding: 10px 0 22px;
}
.root :global(.home-intraday-chart svg) {
width: 100%;
height: 78px;
}
.root :global(.home-intraday-chart line) {
stroke: rgba(148, 163, 184, 0.14);
stroke-width: 1;
}
.root :global(.home-intraday-chart polyline) {
fill: none;
stroke: #2dd4bf;
stroke-width: 2.1;
stroke-linecap: round;
stroke-linejoin: round;
}
.root :global(.home-intraday-chart circle) {
fill: #38bdf8;
stroke: rgba(15, 23, 42, 0.92);
stroke-width: 1.6;
}
.root :global(.home-intraday-axis) {
position: absolute;
inset: auto 0 0;
height: 18px;
}
.root :global(.home-intraday-axis span) {
position: absolute;
bottom: 0;
transform: translateX(-50%);
color: rgba(148, 163, 184, 0.82);
font-size: 10px;
font-weight: 700;
}
.root :global(.home-intraday-meta) {
color: rgba(148, 163, 184, 0.9);
font-size: 11px;
line-height: 1.55;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.root :global(.home-model-stack) {
display: flex;
flex-wrap: wrap;
@@ -12,7 +12,6 @@ import {
} from "@/hooks/useDashboardStore";
import { I18nProvider, useI18n } from "@/hooks/useI18n";
import { CitySidebar } from "@/components/dashboard/CitySidebar";
import { DetailPanel } from "@/components/dashboard/DetailPanel";
import { HeaderBar } from "@/components/dashboard/HeaderBar";
import type {
CityDetail,
@@ -20,6 +19,14 @@ import type {
CitySummary,
RiskLevel,
} from "@/lib/dashboard-types";
import {
getLocalizedAirportDisplay,
getLocalizedCityDisplay,
} from "@/lib/dashboard-home-copy";
import {
getTemperatureChartData,
getWeatherSummary,
} from "@/lib/dashboard-utils";
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
const loadHistoryModal = () =>
@@ -202,6 +209,139 @@ function buildSparklinePoints(values: number[] | undefined) {
.join(" ");
}
type HomeWeatherIconKind =
| "clear"
| "partly"
| "cloudy"
| "rain"
| "storm"
| "mist"
| "wind";
type HomeTrendChart = {
forecastPath: string;
legendText: string;
observationDots: Array<{ cx: number; cy: number; key: string }>;
tickLabels: Array<{ key: string; label: string; x: number }>;
};
function projectHomeTrendPoint(
x: number,
y: number,
xMin: number,
xMax: number,
yMin: number,
yMax: number,
) {
const width = 296;
const height = 78;
const left = 10;
const right = 10;
const top = 8;
const bottom = 12;
const plotWidth = width - left - right;
const plotHeight = height - top - bottom;
const normalizedX =
xMax === xMin ? 0.5 : Math.min(1, Math.max(0, (x - xMin) / (xMax - xMin)));
const normalizedY =
yMax === yMin ? 0.5 : Math.min(1, Math.max(0, (y - yMin) / (yMax - yMin)));
return {
cx: Number((left + normalizedX * plotWidth).toFixed(1)),
cy: Number((top + (1 - normalizedY) * plotHeight).toFixed(1)),
};
}
function getHomeWeatherIconKind(detail?: CityDetail | null, locale = "zh-CN"): HomeWeatherIconKind {
if (!detail) return "cloudy";
const summary = getWeatherSummary(detail, locale === "en-US" ? "en-US" : "zh-CN");
const weatherText = `${summary.weatherIcon} ${summary.weatherText} ${detail.current?.wx_desc || ""} ${detail.current?.cloud_desc || ""}`.toLowerCase();
const cloudCover = Number(detail.hourly_next_48h?.cloud_cover?.[0]);
const windSpeed = Number(detail.current?.wind_speed_kt ?? detail.airport_current?.wind_speed_kt);
if (/⛈|雷|storm|thunder/.test(weatherText)) return "storm";
if (/🌧|🌦|雨|drizzle|shower|rain/.test(weatherText)) return "rain";
if (/🌫|雾|mist|fog|haze/.test(weatherText)) return "mist";
if (/💨|飑|squall/.test(weatherText) || windSpeed >= 22) return "wind";
if (/☀|晴|clear|sunny/.test(weatherText)) return "clear";
if (/🌤|⛅|partly|few|scattered|少云|散云/.test(weatherText)) return "partly";
if (/☁|云|cloud|overcast|阴/.test(weatherText)) return "cloudy";
if (Number.isFinite(cloudCover) && cloudCover <= 15) return "clear";
if (Number.isFinite(cloudCover) && cloudCover <= 55) return "partly";
return "cloudy";
}
function buildHomeTrendChart(
detail?: CityDetail | null,
locale = "zh-CN",
): HomeTrendChart | null {
if (!detail) return null;
const chartData = getTemperatureChartData(detail, locale === "en-US" ? "en-US" : "zh-CN");
if (!chartData) return null;
const forecastSeries = chartData.datasets.hasMgmHourly
? chartData.datasets.mgmHourlySeries
: [...chartData.datasets.debPastSeries, ...chartData.datasets.debFutureSeries];
const observationSeries =
chartData.datasets.metarSeries.length > 0
? chartData.datasets.metarSeries
: chartData.datasets.airportMetarSeries;
if (!forecastSeries.length && !observationSeries.length) return null;
const forecastPath = forecastSeries
.map((point) => {
const projected = projectHomeTrendPoint(
point.x,
point.y,
chartData.xMin,
chartData.xMax,
chartData.min,
chartData.max,
);
return `${projected.cx},${projected.cy}`;
})
.join(" ");
const observationDots = observationSeries.map((point, index) => {
const projected = projectHomeTrendPoint(
point.x,
point.y,
chartData.xMin,
chartData.xMax,
chartData.min,
chartData.max,
);
return {
cx: projected.cx,
cy: projected.cy,
key: `${point.labelTime}-${index}`,
};
});
const tickLabels = chartData.tickLabels
.map((label, index) => {
if (!label) return null;
const minutes = Number.parseInt(String(chartData.times[index] || "0").split(":")[0] || "0", 10) * 60;
const projected = projectHomeTrendPoint(
minutes,
chartData.min,
chartData.xMin,
chartData.xMax,
chartData.min,
chartData.max,
);
return {
key: `${label}-${index}`,
label,
x: projected.cx,
};
})
.filter((item): item is { key: string; label: string; x: number } => item != null);
return {
forecastPath,
legendText: chartData.legendText,
observationDots,
tickLabels,
};
}
function readNumericField(source: unknown, key: string) {
if (!source || typeof source !== "object") return undefined;
const value = (source as Record<string, unknown>)[key];
@@ -347,13 +487,31 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
const isLoading = store.loadingState.cityDetail && store.selectedCity === city.name;
const isPro = store.proAccess.subscriptionActive;
const cityCode = city.icao || detail?.risk?.icao || city.airport;
const subtitle = `${cityCode} · ${city.airport}`;
const localizedCityName = getLocalizedCityDisplay(city, locale, summary, detail);
const localizedAirportName = getLocalizedAirportDisplay(city, locale, detail);
const subtitle = `${cityCode} · ${localizedAirportName}`;
const highRiskLabel =
riskLevel === "high"
? locale === "en-US"
? "High risk"
: "高风险"
: getRiskCopy(riskLevel, locale);
const weatherIconKind = getHomeWeatherIconKind(detail, locale);
const trendChart = buildHomeTrendChart(detail, locale);
const debLabel = locale === "en-US" ? "DEB forecast" : "DEB 预测";
const dayMaxLabel = locale === "en-US" ? "24h max" : "24 小时最高";
const probabilityTitle = locale === "en-US" ? "EMOS probability" : "EMOS 概率";
const marketTitle = locale === "en-US" ? "Market edge" : "市场优势";
const marketEdgeLabel = locale === "en-US" ? "Edge" : "优势";
const marketImpliedLabel = locale === "en-US" ? "Implied" : "市场隐含";
const marketModelLabel = locale === "en-US" ? "Model prob" : "模型概率";
const proLabel = isPro
? locale === "en-US"
? "Pro signal"
: "PRO 信号"
: locale === "en-US"
? "Pro locked"
: "PRO 锁定";
const keySignals = [
{
active: Number(marketEdge) > 0,
@@ -381,7 +539,7 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
];
return (
<aside className="home-intelligence-panel full" aria-label={city.display_name}>
<aside className="home-intelligence-panel full" aria-label={localizedCityName}>
<div className="home-panel-glow" aria-hidden="true" />
<button
type="button"
@@ -404,7 +562,7 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
<div className="home-card-titlebar">
<div>
<h2>{summary?.display_name || detail?.display_name || city.display_name}</h2>
<h2>{localizedCityName}</h2>
<p>{subtitle}</p>
</div>
<span className={clsx("home-risk-badge", String(riskLevel || "other"))}>
@@ -434,12 +592,21 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
{formatTemperature(currentTemp, symbol)}
</span>
</div>
<div className="home-weather-icon" aria-hidden="true">
<div
className={clsx("home-weather-icon", `weather-${weatherIconKind}`)}
aria-hidden="true"
>
<span className="sun" />
<span className="cloud cloud-a" />
<span className="cloud cloud-b" />
<span className="mist mist-a" />
<span className="mist mist-b" />
<span className="wind wind-a" />
<span className="wind wind-b" />
<span className="rain rain-a" />
<span className="rain rain-b" />
<span className="rain rain-c" />
<span className="bolt" />
</div>
<div className="home-max-so-far">
<span>{locale === "en-US" ? "Max so far" : "当前最高"}</span>
@@ -451,7 +618,9 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
<div className="home-deb-card">
<div>
<span>DEB forecast <small>(24h max)</small></span>
<span>
{debLabel} <small>({dayMaxLabel})</small>
</span>
<strong>{formatTemperature(debPrediction, symbol)}</strong>
<em>{formatDelta(debPrediction, currentTemp, symbol)}</em>
</div>
@@ -461,6 +630,41 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
</svg>
</div>
{trendChart ? (
<div className="home-card-section intraday">
<h3>
{locale === "en-US" ? "Intraday trend" : "今日日内走势"}{" "}
<small>{locale === "en-US" ? "compact" : "简版"}</small>
</h3>
<div className="home-intraday-chart">
<svg viewBox="0 0 296 78" aria-hidden="true">
<line x1="10" y1="14" x2="286" y2="14" />
<line x1="10" y1="36" x2="286" y2="36" />
<line x1="10" y1="58" x2="286" y2="58" />
{trendChart.forecastPath ? (
<polyline points={trendChart.forecastPath} />
) : null}
{trendChart.observationDots.map((point) => (
<circle key={point.key} cx={point.cx} cy={point.cy} r="3.2" />
))}
</svg>
<div className="home-intraday-axis">
{trendChart.tickLabels.map((tick) => (
<span key={tick.key} style={{ left: `${tick.x}px` }}>
{tick.label}
</span>
))}
</div>
</div>
<div className="home-intraday-meta">
{trendChart.legendText ||
(locale === "en-US"
? "Intraday observations pending."
: "日内观测序列待补充。")}
</div>
</div>
) : null}
<div className="home-card-section">
<h3>{locale === "en-US" ? "Model stack" : "模型栈"}</h3>
<div className="home-model-stack">
@@ -474,7 +678,9 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
</div>
<div className="home-card-section probability">
<h3>EMOS probability <small>(24h max)</small></h3>
<h3>
{probabilityTitle} <small>({dayMaxLabel})</small>
</h3>
{displayedProbabilities.length ? (
<div className="home-probability-list">
{displayedProbabilities.map((bucket, index) => {
@@ -506,7 +712,9 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
<div className={clsx("home-card-section market", !isPro && "locked")}>
<div className="home-market-header">
<h3>Market edge <small></small></h3>
<h3>
{marketTitle} <small></small>
</h3>
<span>
{hasMarketScan && !spotlight.tradableOpportunity
? locale === "en-US"
@@ -529,13 +737,13 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
</div>
<div className="home-market-metrics">
<span>
Edge <strong>{formatEdge(marketEdge)}</strong>
{marketEdgeLabel} <strong>{formatEdge(marketEdge)}</strong>
</span>
<span>
Implied <strong>{formatProbability(marketProbability)}</strong>
{marketImpliedLabel} <strong>{formatProbability(marketProbability)}</strong>
</span>
<span>
Model prob <strong>{formatProbability(marketModelProbability)}</strong>
{marketModelLabel} <strong>{formatProbability(marketModelProbability)}</strong>
</span>
<svg viewBox="0 0 96 32" aria-hidden="true">
{sparklinePoints ? <polyline points={sparklinePoints} /> : null}
@@ -563,7 +771,7 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
<div className={clsx("home-pro-card", isPro && "active")}>
<div>
<span>{isPro ? "PRO SIGNAL" : "PRO LOCKED"}</span>
<span>{proLabel}</span>
<strong>
{isPro
? locale === "en-US"
@@ -575,8 +783,8 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
</strong>
</div>
{isPro ? (
<button type="button" onClick={() => void store.selectCity(city.name)}>
{locale === "en-US" ? "Open detail" : "完整详情"}
<button type="button" onClick={() => void store.openTodayModal()}>
{locale === "en-US" ? "Open intraday" : "打开日内分析"}
</button>
) : (
<Link href="/account">{locale === "en-US" ? "Upgrade" : "升级"}</Link>
@@ -609,6 +817,7 @@ function OpportunityStrip({ snapshots }: { snapshots: CitySnapshot[] }) {
const symbol = getTempSymbol(city, summary, detail);
const currentTemp = summary?.current?.temp ?? detail?.current?.temp;
const debPrediction = summary?.deb?.prediction ?? detail?.deb?.prediction;
const localizedCityName = getLocalizedCityDisplay(city, locale, summary, detail);
const tier =
city.deb_recent_tier ||
city.risk_level ||
@@ -622,7 +831,7 @@ function OpportunityStrip({ snapshots }: { snapshots: CitySnapshot[] }) {
onClick={() => void store.focusCity(city.name)}
>
<span className={clsx("opportunity-risk-dot", String(tier || "other"))} />
<span className="opportunity-city">{city.display_name}</span>
<span className="opportunity-city">{localizedCityName}</span>
<span className="opportunity-meta">
{formatTemperature(currentTemp, symbol)} / DEB{" "}
{formatTemperature(debPrediction, symbol)}
@@ -704,7 +913,7 @@ function DashboardScreen() {
[store.cities, store.cityDetailsByName, store.citySummariesByName],
);
const showHomepageChrome =
!store.isPanelOpen && !store.historyState.isOpen && !store.futureModalDate;
!store.historyState.isOpen && !store.futureModalDate;
useEffect(() => {
if (didAutoFocusRef.current) return;
@@ -765,7 +974,6 @@ function DashboardScreen() {
<OpportunityStrip snapshots={homepageSnapshots} />
</>
) : null}
<DetailPanel />
{showCitySyncToast ? (
<div className="city-loading-toast" role="status" aria-live="polite">
<span className="city-loading-dot" aria-hidden="true" />
+164
View File
@@ -0,0 +1,164 @@
import type {
CityDetail,
CityListItem,
CitySummary,
} from "@/lib/dashboard-types";
const CITY_NAME_ZH: Record<string, string> = {
Amsterdam: "阿姆斯特丹",
Ankara: "安卡拉",
Atlanta: "亚特兰大",
Austin: "奥斯汀",
Beijing: "北京",
"Buenos Aires": "布宜诺斯艾利斯",
Busan: "釜山",
"Cape Town": "开普敦",
Chengdu: "成都",
Chicago: "芝加哥",
Chongqing: "重庆",
Dallas: "达拉斯",
Denver: "丹佛",
Guangzhou: "广州",
Helsinki: "赫尔辛基",
"Hong Kong": "香港",
Houston: "休斯敦",
Istanbul: "伊斯坦布尔",
Jakarta: "雅加达",
Jeddah: "吉达",
Karachi: "卡拉奇",
"Lau Fau Shan": "流浮山",
Lagos: "拉各斯",
London: "伦敦",
Lucknow: "勒克瑙",
Madrid: "马德里",
Manila: "马尼拉",
Miami: "迈阿密",
Milan: "米兰",
Moscow: "莫斯科",
"Mexico City": "墨西哥城",
Munich: "慕尼黑",
"New York": "纽约",
Panama: "巴拿马城",
"Panama City": "巴拿马城",
Paris: "巴黎",
"Sao Paulo": "圣保罗",
"São Paulo": "圣保罗",
Seattle: "西雅图",
Seoul: "首尔",
Shenzhen: "深圳",
Singapore: "新加坡",
Taipei: "台北",
Tokyo: "东京",
Toronto: "多伦多",
Warsaw: "华沙",
Wellington: "惠灵顿",
Wuhan: "武汉",
};
const AIRPORT_NAME_ZH: Record<string, string> = {
Amsterdam: "史基浦机场",
Ankara: "安卡拉埃森博阿机场",
Atlanta: "哈茨菲尔德-杰克逊亚特兰大国际机场",
Austin: "奥斯汀-伯格斯特龙国际机场",
Beijing: "北京首都国际机场",
"Buenos Aires": "埃塞萨国际机场",
Busan: "金海国际机场",
"Cape Town": "开普敦国际机场",
Chengdu: "成都双流国际机场",
Chicago: "奥黑尔国际机场",
Chongqing: "重庆江北国际机场",
Dallas: "达拉斯爱田机场",
Denver: "丹佛国际机场",
Guangzhou: "广州白云国际机场",
Helsinki: "赫尔辛基机场",
"Hong Kong": "香港天文台总部",
Houston: "乔治布什洲际机场",
Istanbul: "伊斯坦布尔机场",
Jakarta: "苏加诺-哈达国际机场",
Jeddah: "阿卜杜勒-阿齐兹国王国际机场",
Karachi: "真纳国际机场",
"Lau Fau Shan": "流浮山监测站",
Lagos: "穆尔塔拉·穆罕默德国际机场",
London: "希思罗机场",
Lucknow: "乔杜里·查兰·辛格国际机场",
Madrid: "马德里-巴拉哈斯机场",
Manila: "尼诺伊·阿基诺国际机场",
Miami: "迈阿密国际机场",
Milan: "米兰马尔彭萨机场",
Moscow: "谢列梅捷沃国际机场",
"Mexico City": "墨西哥城贝尼托·胡亚雷斯国际机场",
Munich: "慕尼黑机场",
"New York": "拉瓜迪亚机场",
"Panama City": "托库门国际机场",
Paris: "戴高乐机场",
"Sao Paulo": "瓜鲁柳斯国际机场",
"São Paulo": "瓜鲁柳斯国际机场",
Seattle: "西雅图-塔科马国际机场",
Seoul: "仁川国际机场",
Shenzhen: "深圳宝安国际机场",
Singapore: "樟宜机场",
Taipei: "台北松山机场",
Tokyo: "羽田机场",
Toronto: "多伦多皮尔逊国际机场",
Warsaw: "华沙肖邦机场",
Wellington: "惠灵顿机场",
Wuhan: "武汉天河国际机场",
};
const AIRPORT_NAME_EN: Record<string, string> = {
"Hong Kong": "Hong Kong Observatory HQ",
"Lau Fau Shan": "Lau Fau Shan Monitoring Station",
};
function normalizeCityKey(value: string | null | undefined) {
return String(value || "").trim();
}
export function getLocalizedCityName(
cityName: string | null | undefined,
fallback: string | null | undefined,
locale: string,
) {
const key = normalizeCityKey(cityName);
const text = String(fallback || "").trim() || key;
if (locale === "en-US") return text;
return CITY_NAME_ZH[key] || text;
}
export function getLocalizedAirportName(
cityName: string | null | undefined,
fallback: string | null | undefined,
locale: string,
) {
const key = normalizeCityKey(cityName);
const text = String(fallback || "").trim();
if (locale === "en-US") {
return AIRPORT_NAME_EN[key] || text;
}
return AIRPORT_NAME_ZH[key] || text;
}
export function getLocalizedCityDisplay(
city: CityListItem,
locale: string,
summary?: CitySummary | null,
detail?: CityDetail | null,
) {
return getLocalizedCityName(
city.name,
summary?.display_name || detail?.display_name || city.display_name,
locale,
);
}
export function getLocalizedAirportDisplay(
city: CityListItem,
locale: string,
detail?: CityDetail | null,
) {
return getLocalizedAirportName(
city.name,
city.airport || detail?.risk?.airport || detail?.settlement_station?.airport_name,
locale,
);
}