Refine homepage focus panel
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user