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
@@ -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" />