Add bilingual docs center and replace the guide modal
This commit is contained in:
@@ -136,8 +136,7 @@ export function DetailPanel() {
|
||||
const [heavyContentReady, setHeavyContentReady] = useState(false);
|
||||
const isOverlayOpen =
|
||||
Boolean(store.futureModalDate) ||
|
||||
store.historyState.isOpen ||
|
||||
store.isGuideOpen;
|
||||
store.historyState.isOpen;
|
||||
const isVisible =
|
||||
store.isPanelOpen &&
|
||||
Boolean(store.selectedCity) &&
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
const GUIDE_CARDS = {
|
||||
"zh-CN": [
|
||||
{
|
||||
body: "Dynamic Ensemble Blending 是系统的核心预测层。它不是对 ECMWF、GFS、ICON、GEM、JMA 等模型的简单平均,而是结合近期样本表现、当前实况与城市偏置后得到的动态加权结果。",
|
||||
title: "DEB 动态融合预测",
|
||||
},
|
||||
{
|
||||
body: "右侧的结算概率分布基于 DEB 预测值与多模型离散度动态计算。μ 代表当前分布中心,会随着模型、实况和时间变化而变化,不是固定结算值。",
|
||||
title: "结算概率引擎",
|
||||
},
|
||||
{
|
||||
body: "结算源按城市市场定义:米兰(LIMC)、华沙(EPWA)、马德里(LEMD)使用机场 METAR;香港市场使用香港天文台(HKO);台北市场使用交通部中央气象署(CWA)。系统仍会保留 METAR/MGM 作为临近结构参考,并区分观测时间与接收时间。",
|
||||
title: "结算点与主观测源",
|
||||
},
|
||||
{
|
||||
body: "Ankara 不走通用城市逻辑。结算主站以 LTAC / Esenboğa 为准,周边领先信号优先参考 Turkish MGM 站网,其中 Ankara (Bölge/Center) 是重点监控站,不用 Etimesgut 代替。",
|
||||
title: "Ankara 专属增强",
|
||||
},
|
||||
{
|
||||
body: "点击多日预报后的模态框,主要用于分析下一个交易日。6-48 小时趋势以 weather.gov 和 Open-Meteo 为主;0-2 小时临近判断优先看 METAR 与周边站。",
|
||||
title: "未来日期分析",
|
||||
},
|
||||
{
|
||||
body: "历史准确率对账只统计已结算样本。网页端采用近 15 天滚动视图,不把当天尚未结算的样本算入胜率和 MAE。",
|
||||
title: "历史对账规则",
|
||||
},
|
||||
],
|
||||
"en-US": [
|
||||
{
|
||||
body: "Dynamic Ensemble Blending (DEB) is the core prediction layer. It is not a simple average across ECMWF/GFS/ICON/GEM/JMA, but a dynamically weighted blend adjusted by recent model performance, current observations, and city bias.",
|
||||
title: "DEB Dynamic Fusion",
|
||||
},
|
||||
{
|
||||
body: "Settlement probability distribution is dynamically computed from DEB forecast and model spread. μ is the current distribution center and shifts with model updates, observations, and time.",
|
||||
title: "Settlement Probability Engine",
|
||||
},
|
||||
{
|
||||
body: "Settlement source follows market rule by city: Milan (LIMC), Warsaw (EPWA), and Madrid (LEMD) settle on airport METAR, Hong Kong settles on HKO, and Taipei settles on CWA. METAR/MGM are still kept for intraday structure tracking with observation time vs receipt time separated.",
|
||||
title: "Settlement Source Logic",
|
||||
},
|
||||
{
|
||||
body: "Ankara does not follow the generic city path. LTAC / Esenboğa is the settlement station, with Turkish MGM network for leading signals. Ankara (Bölge/Center) is a key station and is not replaced by Etimesgut.",
|
||||
title: "Ankara-specific Enhancement",
|
||||
},
|
||||
{
|
||||
body: "The multi-day modal focuses on next-session analysis. 6-48h trend mainly relies on weather.gov and Open-Meteo; 0-2h nowcast prioritizes METAR and nearby stations.",
|
||||
title: "Future-date Analysis",
|
||||
},
|
||||
{
|
||||
body: "History reconciliation only uses settled samples. The web dashboard uses a rolling 15-day window and excludes same-day unsettled samples from hit-rate and MAE.",
|
||||
title: "History Rules",
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export function GuideModal() {
|
||||
const store = useDashboardStore();
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
if (!store.isGuideOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="guide-modal-title"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
store.closeGuide();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="modal-content large">
|
||||
<div className="modal-header">
|
||||
<h2 id="guide-modal-title">{t("guide.title")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
aria-label={t("guide.closeAria")}
|
||||
onClick={store.closeGuide}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="guide-grid">
|
||||
{GUIDE_CARDS[locale].map((card) => (
|
||||
<div key={card.title} className="guide-card">
|
||||
<h3>{card.title}</h3>
|
||||
<p>{card.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="guide-footer">{t("guide.footer")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import { LogIn, UserRound } from "lucide-react";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
@@ -14,8 +15,11 @@ import {
|
||||
export function HeaderBar() {
|
||||
const store = useDashboardStore();
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const supabaseReady = hasSupabasePublicEnv();
|
||||
const docsHref = "/docs/intro";
|
||||
const docsActive = pathname?.startsWith("/docs");
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -78,6 +82,15 @@ export function HeaderBar() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={docsHref}
|
||||
className={clsx("info-btn", docsActive && "active")}
|
||||
title={t("header.docsAria")}
|
||||
aria-label={t("header.docsAria")}
|
||||
>
|
||||
{t("header.docs")}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={accountHref}
|
||||
className="account-btn"
|
||||
@@ -88,15 +101,14 @@ export function HeaderBar() {
|
||||
<span>{accountLabel}</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
<Link
|
||||
href={docsHref}
|
||||
className="info-btn"
|
||||
title={t("header.infoAria")}
|
||||
aria-label={t("header.infoAria")}
|
||||
onClick={store.openGuide}
|
||||
>
|
||||
{t("header.info")}
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<div className="live-badge" id="liveBadge">
|
||||
<span className="pulse-dot" />
|
||||
@@ -115,4 +127,4 @@ export function HeaderBar() {
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ export function MapCanvas() {
|
||||
selectedDetail: store.selectedDetail,
|
||||
suspendMotion:
|
||||
Boolean(store.futureModalDate) ||
|
||||
store.historyState.isOpen ||
|
||||
store.isGuideOpen,
|
||||
store.historyState.isOpen,
|
||||
isLoadingDetail: store.loadingState.cityDetail,
|
||||
});
|
||||
|
||||
|
||||
@@ -21,15 +21,6 @@ const MapCanvas = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
const GuideModal = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/GuideModal").then((module) => module.GuideModal),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
const HistoryModal = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/HistoryModal").then(
|
||||
@@ -57,7 +48,6 @@ function DashboardScreen() {
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
void import("@/components/dashboard/GuideModal");
|
||||
void import("@/components/dashboard/HistoryModal");
|
||||
void import("@/components/dashboard/FutureForecastModal");
|
||||
}, []);
|
||||
@@ -73,10 +63,6 @@ function DashboardScreen() {
|
||||
store.closeHistory();
|
||||
return;
|
||||
}
|
||||
if (store.isGuideOpen) {
|
||||
store.closeGuide();
|
||||
return;
|
||||
}
|
||||
if (store.isPanelOpen) {
|
||||
store.closePanel();
|
||||
}
|
||||
@@ -100,7 +86,6 @@ function DashboardScreen() {
|
||||
<HeaderBar />
|
||||
<CitySidebar />
|
||||
<DetailPanel />
|
||||
{store.isGuideOpen && <GuideModal />}
|
||||
{store.historyState.isOpen && <HistoryModal />}
|
||||
{store.futureModalDate && <FutureForecastModal />}
|
||||
{showLoading && (
|
||||
|
||||
Reference in New Issue
Block a user