清理旧 FutureForecast 组件链和 scan-root-styles 死引用
This commit is contained in:
@@ -1,80 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import type { useI18n } from "@/hooks/useI18n";
|
||||
import type { getFutureModalView } from "@/lib/dashboard-utils";
|
||||
import {
|
||||
FutureModelForecastPanel,
|
||||
FutureProbabilityPanel,
|
||||
FutureTemperaturePathChart,
|
||||
} from "./FutureForecastModalPanels";
|
||||
|
||||
type DashboardDetail = NonNullable<
|
||||
ReturnType<typeof useDashboardStore>["selectedDetail"]
|
||||
>;
|
||||
type FutureModalView = ReturnType<typeof getFutureModalView>;
|
||||
type TranslationFn = ReturnType<typeof useI18n>["t"];
|
||||
|
||||
export function FutureForecastForwardView({
|
||||
dateStr,
|
||||
detail,
|
||||
t,
|
||||
view,
|
||||
}: {
|
||||
dateStr: string;
|
||||
detail: DashboardDetail;
|
||||
t: TranslationFn;
|
||||
view: FutureModalView;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="future-forward-stats">
|
||||
<div className="future-forward-stat-card">
|
||||
<span className="label">{t("future.targetForecast")}</span>
|
||||
<span className="val">
|
||||
{view.forecastEntry?.max_temp ?? "--"}
|
||||
{detail.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="future-forward-stat-card">
|
||||
<span className="label">{t("future.deb")}</span>
|
||||
<span className="val">
|
||||
{view.deb ?? "--"}
|
||||
{detail.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="future-forward-stat-card">
|
||||
<span className="label">{t("future.mu")}</span>
|
||||
<span className="val">
|
||||
{view.mu != null
|
||||
? `${view.mu.toFixed(1)}${detail.temp_symbol}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="future-forward-stat-card">
|
||||
<span className="label">{t("future.score")}</span>
|
||||
<span className="val">
|
||||
{view.front.score > 0 ? "+" : ""}
|
||||
{view.front.score}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="future-modal-section">
|
||||
<h3>{t("future.targetTempTrend")}</h3>
|
||||
<FutureTemperaturePathChart dateStr={dateStr} forceToday={false} />
|
||||
</section>
|
||||
|
||||
<div className="future-modal-grid">
|
||||
<section className="future-modal-section">
|
||||
<h3>{t("future.probability")}</h3>
|
||||
<FutureProbabilityPanel detail={detail} targetDate={dateStr} hideTitle />
|
||||
</section>
|
||||
<section className="future-modal-section">
|
||||
<h3>{t("future.models")}</h3>
|
||||
<FutureModelForecastPanel detail={detail} targetDate={dateStr} hideTitle />
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useDashboardModal,
|
||||
useDashboardSelection,
|
||||
useProAccess,
|
||||
} from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { FutureForecastModalContent } from "./FutureForecastModalContent";
|
||||
|
||||
export function FutureForecastModal() {
|
||||
const modal = useDashboardModal();
|
||||
const selection = useDashboardSelection();
|
||||
const proAccess = useProAccess();
|
||||
const { locale, t } = useI18n();
|
||||
const detail = selection.selectedDetail;
|
||||
const dateStr = modal.futureModalDate;
|
||||
|
||||
if (!detail || !dateStr) return null;
|
||||
|
||||
return (
|
||||
<FutureForecastModalContent
|
||||
modal={modal}
|
||||
proAccess={proAccess.proAccess}
|
||||
locale={locale}
|
||||
t={t}
|
||||
detail={detail}
|
||||
dateStr={dateStr}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import type { IntradayMeteorologySignal } from "@/lib/dashboard-types";
|
||||
|
||||
export const TODAY_MARKET_SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000;
|
||||
|
||||
export function normalizeMarketValue(value?: number | null) {
|
||||
if (value == null) return null;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
|
||||
return Math.max(0, Math.min(1, numeric));
|
||||
}
|
||||
|
||||
export function formatMinuteAxisLabel(value: number) {
|
||||
if (!Number.isFinite(value)) return "";
|
||||
const total = Math.max(0, Math.round(value));
|
||||
const hour = Math.floor(total / 60) % 24;
|
||||
const minute = total % 60;
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatMarketPercent(value?: number | null) {
|
||||
const normalized = normalizeMarketValue(value);
|
||||
if (normalized == null) return "--";
|
||||
return `${(normalized * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function formatBucketLabel(
|
||||
bucket?: {
|
||||
label?: string | null;
|
||||
bucket?: string | null;
|
||||
range?: string | null;
|
||||
value?: number | null;
|
||||
temp?: number | null;
|
||||
} | null,
|
||||
) {
|
||||
if (!bucket) return "--";
|
||||
const direct =
|
||||
String(bucket.label || "").trim() ||
|
||||
String(bucket.bucket || "").trim() ||
|
||||
String(bucket.range || "").trim();
|
||||
if (direct) {
|
||||
let str = direct.toUpperCase().replace(/\s+/g, "");
|
||||
str = str.replace(/°?C($|\+|-)/g, "℃$1");
|
||||
if (!str.includes("℃") && /[0-9]/.test(str)) {
|
||||
str += "℃";
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
const temp = Number(bucket.value ?? bucket.temp);
|
||||
if (Number.isFinite(temp)) {
|
||||
return `${Math.round(temp)}℃`;
|
||||
}
|
||||
return "--";
|
||||
}
|
||||
|
||||
export function parseBucketBoundaries(
|
||||
bucket?: {
|
||||
label?: string | null;
|
||||
bucket?: string | null;
|
||||
range?: string | null;
|
||||
value?: number | null;
|
||||
temp?: number | null;
|
||||
} | null,
|
||||
) {
|
||||
if (!bucket) return null;
|
||||
const raw =
|
||||
String(bucket.label || "").trim() ||
|
||||
String(bucket.bucket || "").trim() ||
|
||||
String(bucket.range || "").trim();
|
||||
if (!raw) return null;
|
||||
const numbers = Array.from(raw.matchAll(/-?\d+(?:\.\d+)?/g)).map((match) =>
|
||||
Number(match[0]),
|
||||
);
|
||||
if (!numbers.length) return null;
|
||||
if (raw.includes("+")) {
|
||||
return {
|
||||
lower: numbers[0] ?? null,
|
||||
upper: null as number | null,
|
||||
boundaryLabel: `${numbers[0]}°C`,
|
||||
};
|
||||
}
|
||||
if (numbers.length >= 2) {
|
||||
return {
|
||||
lower: numbers[0],
|
||||
upper: numbers[1],
|
||||
boundaryLabel: null as string | null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
lower: numbers[0],
|
||||
upper: null as number | null,
|
||||
boundaryLabel: `${numbers[0]}°C`,
|
||||
};
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function parseClockMinutes(value?: string | null) {
|
||||
const text = String(value || "").trim();
|
||||
const match = text.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
export function parseLeadingNumber(value?: string | number | null) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
const text = String(value || "").trim();
|
||||
const match = text.match(/[-+]?\d+(?:\.\d+)?/);
|
||||
if (!match) return null;
|
||||
const numeric = Number(match[0]);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
export function parsePercentFromText(value?: string | number | null) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return clamp(value, 0, 100);
|
||||
}
|
||||
const text = String(value || "").trim();
|
||||
const percentMatch = text.match(/([-+]?\d+(?:\.\d+)?)\s*%/);
|
||||
if (percentMatch) {
|
||||
const numeric = Number(percentMatch[1]);
|
||||
return Number.isFinite(numeric) ? clamp(numeric, 0, 100) : null;
|
||||
}
|
||||
return parseLeadingNumber(text);
|
||||
}
|
||||
|
||||
export function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "high") return locale === "en-US" ? "High" : "高";
|
||||
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
|
||||
if (normalized === "low") return locale === "en-US" ? "Low" : "低";
|
||||
return locale === "en-US" ? "Pending" : "待确认";
|
||||
}
|
||||
|
||||
export function formatSignalDirection(value?: string | null, locale = "zh-CN") {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "support")
|
||||
return locale === "en-US" ? "Support" : "支持升温";
|
||||
if (normalized === "suppress")
|
||||
return locale === "en-US" ? "Suppress" : "压制峰值";
|
||||
return locale === "en-US" ? "Neutral" : "中性";
|
||||
}
|
||||
|
||||
export function formatSignalStrength(value?: string | null, locale = "zh-CN") {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "strong") return locale === "en-US" ? "Strong" : "强";
|
||||
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
|
||||
return locale === "en-US" ? "Weak" : "弱";
|
||||
}
|
||||
|
||||
export function signalTone(signal?: IntradayMeteorologySignal | null) {
|
||||
const direction = String(signal?.direction || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (direction === "support") return "cyan";
|
||||
if (direction === "suppress") return "amber";
|
||||
return "blue";
|
||||
}
|
||||
|
||||
export function localizedText(
|
||||
locale: string,
|
||||
primary?: string | null,
|
||||
english?: string | null,
|
||||
) {
|
||||
const en = String(english || "").trim();
|
||||
const value = String(primary || "").trim();
|
||||
if (locale === "en-US" && en) return en;
|
||||
return value || en;
|
||||
}
|
||||
|
||||
export function localizedList(
|
||||
locale: string,
|
||||
primary?: string[] | null,
|
||||
english?: string[] | null,
|
||||
) {
|
||||
const en = Array.isArray(english)
|
||||
? english.filter((item) => String(item || "").trim())
|
||||
: [];
|
||||
const value = Array.isArray(primary)
|
||||
? primary.filter((item) => String(item || "").trim())
|
||||
: [];
|
||||
if (locale === "en-US" && en.length) return en;
|
||||
return value.length ? value : en;
|
||||
}
|
||||
|
||||
export function getTrendMetricVisual(metric: {
|
||||
label?: string;
|
||||
value?: string;
|
||||
tone?: string;
|
||||
}) {
|
||||
const label = String(metric.label || "").toLowerCase();
|
||||
const value = String(metric.value || "");
|
||||
const numeric = parseLeadingNumber(value);
|
||||
|
||||
if (label.includes("降水") || label.includes("precip")) {
|
||||
const precipPercent = parsePercentFromText(value);
|
||||
if (precipPercent == null) return null;
|
||||
return {
|
||||
mode: "fill" as const,
|
||||
percent: precipPercent,
|
||||
tone: "cold" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (numeric == null) return null;
|
||||
|
||||
if (label.includes("温度") || label.includes("temp")) {
|
||||
return {
|
||||
mode: "center" as const,
|
||||
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
|
||||
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
|
||||
};
|
||||
}
|
||||
|
||||
if (label.includes("露点") || label.includes("dew")) {
|
||||
return {
|
||||
mode: "center" as const,
|
||||
percent: clamp(50 + (numeric / 3) * 50, 0, 100),
|
||||
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
|
||||
};
|
||||
}
|
||||
|
||||
if (label.includes("气压") || label.includes("pressure")) {
|
||||
return {
|
||||
mode: "center" as const,
|
||||
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
|
||||
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
|
||||
};
|
||||
}
|
||||
|
||||
if (label.includes("云量") || label.includes("cloud")) {
|
||||
return {
|
||||
mode: "center" as const,
|
||||
percent: clamp(50 + (numeric / 40) * 50, 0, 100),
|
||||
tone: numeric >= 0 ? ("cold" as const) : ("warm" as const),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
|
||||
type FutureForecastModalHeaderProps = {
|
||||
cityDisplayName: string;
|
||||
dateStr: string;
|
||||
isAnyLayerSyncing: boolean;
|
||||
isPro: boolean;
|
||||
isProLoading: boolean;
|
||||
isToday: boolean;
|
||||
locale: Locale;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
};
|
||||
|
||||
export function FutureForecastModalHeader({
|
||||
cityDisplayName,
|
||||
dateStr,
|
||||
isAnyLayerSyncing,
|
||||
isPro,
|
||||
isProLoading,
|
||||
isToday,
|
||||
locale,
|
||||
onClose,
|
||||
onRefresh,
|
||||
t,
|
||||
}: FutureForecastModalHeaderProps) {
|
||||
const cityLabel = cityDisplayName.toUpperCase();
|
||||
return (
|
||||
<div className="modal-header">
|
||||
<div className="modal-title-stack">
|
||||
<div className="modal-overline">
|
||||
<span>{locale === "en-US" ? "Analysis workspace" : "分析工作台"}</span>
|
||||
<span className="modal-overline-sep">•</span>
|
||||
<span>{cityLabel}</span>
|
||||
</div>
|
||||
<h2 id="future-modal-title" className="future-modal-title-with-actions">
|
||||
<span>
|
||||
{isToday
|
||||
? t("future.todayTitle", {
|
||||
city: cityLabel,
|
||||
})
|
||||
: t("future.dateTitle", {
|
||||
city: cityLabel,
|
||||
date: dateStr,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
className={clsx("future-refresh-btn", isAnyLayerSyncing && "spinning")}
|
||||
disabled={!isPro || isProLoading}
|
||||
onClick={onRefresh}
|
||||
title={
|
||||
!isPro
|
||||
? locale === "en-US"
|
||||
? "Pro subscription required"
|
||||
: "需要 Pro 订阅"
|
||||
: locale === "en-US"
|
||||
? "Refresh Data"
|
||||
: "刷新数据"
|
||||
}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
</h2>
|
||||
<div className="modal-subtitle">
|
||||
{isToday
|
||||
? locale === "en-US"
|
||||
? "Base signal first, then probability and model layers."
|
||||
: "先看基础信号,再看概率层和模型层。"
|
||||
: locale === "en-US"
|
||||
? "Forward date view with phased model and structure sync."
|
||||
: "未来日期视图,模型层与结构层分阶段补齐。"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
aria-label={isToday ? t("future.closeTodayAria") : t("future.closeDateAria")}
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import type { MarketScan } from "@/lib/dashboard-types";
|
||||
|
||||
const DailyTemperatureChart = dynamic(
|
||||
() =>
|
||||
import("./FutureForecastModalChart").then(
|
||||
(module) => module.DailyTemperatureChart,
|
||||
),
|
||||
{
|
||||
loading: () => <div className="history-chart-wrapper future-chart-wrapper" />,
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const ProbabilityDistribution = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/PanelSections").then(
|
||||
(module) => module.ProbabilityDistribution,
|
||||
),
|
||||
{
|
||||
loading: () => <div className="future-v2-panel-loading" />,
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const ModelForecast = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/PanelSections").then(
|
||||
(module) => module.ModelForecast,
|
||||
),
|
||||
{
|
||||
loading: () => <div className="future-v2-panel-loading" />,
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
type DashboardDetail = NonNullable<
|
||||
ReturnType<typeof useDashboardStore>["selectedDetail"]
|
||||
>;
|
||||
|
||||
export function FutureTemperaturePathChart({
|
||||
dateStr,
|
||||
forceToday,
|
||||
}: {
|
||||
dateStr: string;
|
||||
forceToday: boolean;
|
||||
}) {
|
||||
return <DailyTemperatureChart dateStr={dateStr} forceToday={forceToday} />;
|
||||
}
|
||||
|
||||
export function FutureProbabilityPanel({
|
||||
detail,
|
||||
targetDate,
|
||||
marketScan,
|
||||
hideTitle = true,
|
||||
}: {
|
||||
detail: DashboardDetail;
|
||||
targetDate: string;
|
||||
marketScan?: MarketScan | null;
|
||||
hideTitle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ProbabilityDistribution
|
||||
detail={detail}
|
||||
targetDate={targetDate}
|
||||
marketScan={marketScan}
|
||||
hideTitle={hideTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FutureModelForecastPanel({
|
||||
detail,
|
||||
targetDate,
|
||||
hideTitle = true,
|
||||
}: {
|
||||
detail: DashboardDetail;
|
||||
targetDate: string;
|
||||
hideTitle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ModelForecast detail={detail} targetDate={targetDate} hideTitle={hideTitle} />
|
||||
);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { getTodayPaceView } from "@/lib/pace-utils";
|
||||
import { WeatherIcon } from "./FutureForecastModalWeatherIcon";
|
||||
|
||||
type Locale = string;
|
||||
type TodayPaceView = NonNullable<ReturnType<typeof getTodayPaceView>>;
|
||||
|
||||
type WeatherSummaryView = {
|
||||
weatherIcon: string;
|
||||
weatherText: string;
|
||||
};
|
||||
|
||||
type DaylightProgressView = {
|
||||
phase: string;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
export type FuturePaceSignalItem = {
|
||||
label: string;
|
||||
tone: "cyan" | "blue" | "amber";
|
||||
status: string;
|
||||
value: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export function FutureAnchorStatusCard({
|
||||
locale,
|
||||
currentTempText,
|
||||
weatherSummary,
|
||||
obsTime,
|
||||
topObservedTemp,
|
||||
tempSymbol,
|
||||
gapToBaseBucket,
|
||||
pathStatus,
|
||||
}: {
|
||||
locale: Locale;
|
||||
currentTempText: string;
|
||||
weatherSummary: WeatherSummaryView;
|
||||
obsTime?: string | null;
|
||||
topObservedTemp: number | string | null | undefined;
|
||||
tempSymbol: string;
|
||||
gapToBaseBucket: number | null;
|
||||
pathStatus: string;
|
||||
}) {
|
||||
const observedHigh = topObservedTemp ?? "--";
|
||||
|
||||
return (
|
||||
<section className="future-v2-card future-v2-hero-card">
|
||||
<div className="future-v2-card-head">
|
||||
<h3 className="future-v2-hero-title">
|
||||
{locale === "en-US" ? "Anchor Status" : "锚点状态"}
|
||||
</h3>
|
||||
<div className="future-v2-card-kicker">
|
||||
{locale === "en-US"
|
||||
? "Settlement anchor and current clock"
|
||||
: "结算锚点与当前时钟"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-v2-hero-main">
|
||||
<div className="future-v2-hero-temp">{currentTempText}</div>
|
||||
<div className="future-v2-hero-divider" />
|
||||
<div className="future-v2-hero-weather">
|
||||
<span className="future-v2-hero-icon">
|
||||
<WeatherIcon emoji={weatherSummary.weatherIcon} size={42} />
|
||||
</span>
|
||||
<span>{weatherSummary.weatherText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-v2-hero-obs">@{obsTime || "--"}</div>
|
||||
<div className="future-v2-mini-grid">
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "High so far" : "日内已见高点"}
|
||||
value={`${observedHigh}${tempSymbol}`}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Anchor clock" : "锚点时钟"}
|
||||
value={obsTime || "--"}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Gap to base" : "距基准档"}
|
||||
value={
|
||||
gapToBaseBucket != null
|
||||
? `${gapToBaseBucket.toFixed(1)}${tempSymbol}`
|
||||
: "--"
|
||||
}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Path state" : "路径状态"}
|
||||
value={pathStatus}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function FuturePaceCard({
|
||||
locale,
|
||||
paceView,
|
||||
tempSymbol,
|
||||
signalItems,
|
||||
}: {
|
||||
locale: Locale;
|
||||
paceView: TodayPaceView;
|
||||
tempSymbol: string;
|
||||
signalItems: FuturePaceSignalItem[];
|
||||
}) {
|
||||
return (
|
||||
<section className="future-v2-card future-v2-pace-card future-v2-focus-card">
|
||||
<div className="future-v2-card-head">
|
||||
<h4 className="future-v2-card-title">
|
||||
{locale === "en-US" ? "Current Pace" : "当前节奏"}
|
||||
</h4>
|
||||
<div className="future-v2-card-kicker">
|
||||
{locale === "en-US"
|
||||
? "Expected now vs airport anchor"
|
||||
: "此刻应到 vs 机场锚点"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-v2-pace-head">
|
||||
<span className="future-v2-pace-kicker">{paceView.kicker}</span>
|
||||
<FutureSignalTag tone={paceView.biasTone}>{paceView.badge}</FutureSignalTag>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"future-v2-pace-delta",
|
||||
paceView.biasTone === "cold" && "cold",
|
||||
paceView.biasTone === "neutral" && "neutral",
|
||||
paceView.biasTone === "warm" && "warm",
|
||||
)}
|
||||
>
|
||||
{paceView.deltaText}
|
||||
</div>
|
||||
<div className="future-v2-pace-summary">{paceView.summary}</div>
|
||||
<div className="future-v2-pace-meter">
|
||||
<span className="future-v2-pace-meter-midline" />
|
||||
<span
|
||||
className={clsx(
|
||||
"future-v2-pace-meter-fill",
|
||||
paceView.biasTone === "cold" && "cold",
|
||||
paceView.biasTone === "neutral" && "neutral",
|
||||
paceView.biasTone === "warm" && "warm",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--pace-left": `${paceView.meterLeft}%`,
|
||||
"--pace-width": `${paceView.meterWidth}%`,
|
||||
} as CSSProperties & {
|
||||
"--pace-left": string;
|
||||
"--pace-width": string;
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Expected now" : "预期此刻"}
|
||||
value={`${paceView.expectedNow.toFixed(1)}${tempSymbol}`}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={paceView.observedLabel}
|
||||
value={`${paceView.observedNow.toFixed(1)}${tempSymbol}`}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={paceView.paceAdjustedLabel}
|
||||
value={
|
||||
paceView.paceAdjustedHigh != null
|
||||
? `${paceView.paceAdjustedHigh.toFixed(1)}${tempSymbol}`
|
||||
: "--"
|
||||
}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Peak window" : "峰值窗口"}
|
||||
value={paceView.peakWindowText}
|
||||
/>
|
||||
</div>
|
||||
{signalItems.length ? (
|
||||
<div className="future-v2-pace-signal-grid">
|
||||
{signalItems.map((item) => (
|
||||
<div key={item.label} className="future-v2-pace-signal-card">
|
||||
<div className="future-v2-signal-head">
|
||||
<span>{item.label}</span>
|
||||
<FutureSignalTag tone={item.tone}>{item.status}</FutureSignalTag>
|
||||
</div>
|
||||
<strong>{item.value}</strong>
|
||||
<div className="future-v2-pace-signal-note">{item.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function FuturePaceLoadingCard({ locale }: { locale: Locale }) {
|
||||
return (
|
||||
<section className="future-v2-card future-v2-support-card">
|
||||
<div className="future-v2-card-head">
|
||||
<h4 className="future-v2-card-title">
|
||||
{locale === "en-US" ? "Current Pace" : "当前节奏"}
|
||||
</h4>
|
||||
<div className="future-v2-card-kicker">
|
||||
{locale === "en-US"
|
||||
? "Backfilling intraday pace context"
|
||||
: "正在补齐日内节奏上下文"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-trend-summary future-trend-summary-muted">
|
||||
{locale === "en-US"
|
||||
? "Expected-now pace, boundary risk, and airport-vs-network cues are loading in the background."
|
||||
: "预期此刻节奏、边界风险和机场对比站网信号正在后台补齐。"}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FutureMiniMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="future-v2-mini-item">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FutureSignalTag({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: string;
|
||||
children: string;
|
||||
}) {
|
||||
return (
|
||||
<em
|
||||
className={clsx(
|
||||
"future-v2-signal-tag",
|
||||
(tone === "cold" || tone === "cyan") && "cyan",
|
||||
(tone === "neutral" || tone === "blue") && "blue",
|
||||
(tone === "warm" || tone === "amber") && "amber",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</em>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { formatConfidenceLabel } from "./FutureForecastModal.utils";
|
||||
|
||||
export function FutureForecastTodayDecisionBrief({
|
||||
anchorRuleText,
|
||||
anchorSourceLabel,
|
||||
baseCaseBucket,
|
||||
confidence,
|
||||
displayName,
|
||||
downsideBucket,
|
||||
gapToBaseText,
|
||||
locale,
|
||||
meteorologyHeadline,
|
||||
nextObservationLabel,
|
||||
nextObservationTime,
|
||||
pathStatus,
|
||||
settlementStationName,
|
||||
upsideBucket,
|
||||
}: {
|
||||
anchorRuleText: string;
|
||||
anchorSourceLabel: string;
|
||||
baseCaseBucket: string;
|
||||
confidence: string | null | undefined;
|
||||
displayName: string;
|
||||
downsideBucket: string | null | undefined;
|
||||
gapToBaseText: string;
|
||||
locale: string;
|
||||
meteorologyHeadline: string;
|
||||
nextObservationLabel: string;
|
||||
nextObservationTime: string;
|
||||
pathStatus: string;
|
||||
settlementStationName: string;
|
||||
upsideBucket: string | null | undefined;
|
||||
}) {
|
||||
return (
|
||||
<section className="future-v2-meteorology-brief">
|
||||
<div className="future-v2-meteorology-copy">
|
||||
<div className="future-v2-anchor-row">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Professional meteorology read" : "专业气象判断"}
|
||||
</div>
|
||||
<span className="future-v2-anchor-source">{anchorSourceLabel}</span>
|
||||
</div>
|
||||
<h3>{meteorologyHeadline}</h3>
|
||||
<p className="future-v2-anchor-rule">{anchorRuleText}</p>
|
||||
<div className="future-v2-meteorology-meta">
|
||||
<span>
|
||||
{locale === "en-US" ? "Confidence" : "置信度"} ·{" "}
|
||||
{formatConfidenceLabel(confidence, locale)}
|
||||
</span>
|
||||
<span>
|
||||
{locale === "en-US" ? "Path state" : "路径状态"} · {pathStatus}
|
||||
</span>
|
||||
<span>
|
||||
{nextObservationLabel} · {nextObservationTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-v2-decision-rail" aria-label={displayName}>
|
||||
<div className="future-v2-decision-anchor">
|
||||
<span>{locale === "en-US" ? "Anchor" : "锚点"}</span>
|
||||
<strong>{settlementStationName}</strong>
|
||||
<small>{anchorSourceLabel}</small>
|
||||
</div>
|
||||
<div className="future-v2-decision-grid">
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Base" : "基准"}</span>
|
||||
<strong>{baseCaseBucket || "--"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Upside" : "上修"}</span>
|
||||
<strong>{upsideBucket || "--"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Downside" : "下修"}</span>
|
||||
<strong>{downsideBucket || "--"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Gap to base" : "距基准还差"}</span>
|
||||
<strong>{gapToBaseText}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
formatSignalDirection,
|
||||
formatSignalStrength,
|
||||
localizedText,
|
||||
signalTone,
|
||||
} from "./FutureForecastModal.utils";
|
||||
|
||||
type MeteorologySignal = {
|
||||
direction?: string | null;
|
||||
label?: string | null;
|
||||
label_en?: string | null;
|
||||
strength?: string | null;
|
||||
summary?: string | null;
|
||||
summary_en?: string | null;
|
||||
};
|
||||
|
||||
export function FutureForecastTodayEvidenceGrid({
|
||||
airportMetarAnchor,
|
||||
confirmationRules,
|
||||
invalidationRules,
|
||||
locale,
|
||||
meteorologySignals,
|
||||
modelSummary,
|
||||
}: {
|
||||
airportMetarAnchor: boolean;
|
||||
confirmationRules: string[];
|
||||
invalidationRules: string[];
|
||||
locale: string;
|
||||
meteorologySignals: MeteorologySignal[];
|
||||
modelSummary: string;
|
||||
}) {
|
||||
const fallbackInvalidationRule =
|
||||
locale === "en-US"
|
||||
? "If observations stop tracking the expected curve, wait for the next refresh."
|
||||
: "若实测不再贴近预期曲线,等待下一次刷新确认。";
|
||||
const fallbackConfirmationRule =
|
||||
locale === "en-US"
|
||||
? airportMetarAnchor
|
||||
? "Keep watching the next anchor METAR report."
|
||||
: "Keep watching the next official anchor observation."
|
||||
: airportMetarAnchor
|
||||
? "继续观察下一次锚点 METAR 报文。"
|
||||
: "继续观察下一次官方锚点观测。";
|
||||
|
||||
return (
|
||||
<div className="future-v2-meteorology-grid">
|
||||
<section className="future-modal-section future-v2-evidence-panel">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Evidence chain" : "气象证据链"}
|
||||
</div>
|
||||
<h3>{locale === "en-US" ? "Signal Contributions" : "信号贡献"}</h3>
|
||||
</div>
|
||||
<div className="future-v2-evidence-list">
|
||||
{meteorologySignals.length > 0 ? (
|
||||
meteorologySignals.map((signal, index) => (
|
||||
<div
|
||||
key={`${signal.label || "signal"}-${index}`}
|
||||
className={clsx("future-v2-evidence-row", signalTone(signal))}
|
||||
>
|
||||
<div className="future-v2-evidence-head">
|
||||
<strong>
|
||||
{localizedText(locale, signal.label, signal.label_en) || "--"}
|
||||
</strong>
|
||||
<span>
|
||||
{formatSignalDirection(signal.direction, locale)} ·{" "}
|
||||
{formatSignalStrength(signal.strength, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
{localizedText(locale, signal.summary, signal.summary_en) ||
|
||||
"--"}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="future-text-block">
|
||||
{locale === "en-US"
|
||||
? "Meteorology signals are still loading."
|
||||
: "气象信号仍在加载。"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="future-modal-section future-v2-rule-panel">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Failure modes" : "失效条件"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US" ? "What Downgrades the Read" : "什么会让判断降级"}
|
||||
</h3>
|
||||
</div>
|
||||
<ul className="future-v2-rule-list">
|
||||
{(invalidationRules.length > 0
|
||||
? invalidationRules
|
||||
: [fallbackInvalidationRule]
|
||||
).map((rule, index) => (
|
||||
<li key={`${rule}-${index}`}>{rule}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="future-modal-section future-v2-rule-panel">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Confirmation" : "确认条件"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US" ? "What Confirms the Path" : "什么会确认主路径"}
|
||||
</h3>
|
||||
</div>
|
||||
<ul className="future-v2-rule-list">
|
||||
{(confirmationRules.length > 0
|
||||
? confirmationRules
|
||||
: [fallbackConfirmationRule]
|
||||
).map((rule, index) => (
|
||||
<li key={`${rule}-${index}`}>{rule}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="future-v2-model-note">{modelSummary}</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { MarketScan, CityDetail } from "@/lib/dashboard-types";
|
||||
import type { FuturePaceSignalItem } from "./FutureForecastTodayCards";
|
||||
import { FutureForecastTodayEvidenceGrid } from "./FutureForecastTodayEvidenceGrid";
|
||||
import {
|
||||
FutureModelForecastPanel,
|
||||
FutureProbabilityPanel,
|
||||
FutureTemperaturePathChart,
|
||||
} from "./FutureForecastModalPanels";
|
||||
import {
|
||||
FutureAnchorStatusCard,
|
||||
FuturePaceCard,
|
||||
FuturePaceLoadingCard,
|
||||
} from "./FutureForecastTodayCards";
|
||||
|
||||
type Locale = string;
|
||||
|
||||
interface WeatherSummaryView {
|
||||
weatherIcon: string;
|
||||
weatherText: string;
|
||||
}
|
||||
|
||||
interface DaylightProgressView {
|
||||
phase: string;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type PaceView = any;
|
||||
|
||||
export interface FutureForecastTodayLayoutProps {
|
||||
locale: Locale;
|
||||
isToday: boolean;
|
||||
dateStr: string;
|
||||
detail: CityDetail;
|
||||
currentTempText: string;
|
||||
weatherSummary: WeatherSummaryView;
|
||||
daylightProgress: DaylightProgressView | null;
|
||||
topObservedTemp: number | string | null | undefined;
|
||||
gapToBaseBucket: number | null;
|
||||
pathStatus: string;
|
||||
showDeferredTodaySections: boolean;
|
||||
paceView: PaceView | null;
|
||||
paceSignalItems: FuturePaceSignalItem[];
|
||||
baseCaseBucket: string | undefined;
|
||||
upsideBucket: string | null | undefined;
|
||||
nextObservationTime: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
airportMetarAnchor: any;
|
||||
confirmationRules: string[];
|
||||
invalidationRules: string[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
meteorologySignals: any[];
|
||||
modelSummary: string;
|
||||
probabilityTitle: string;
|
||||
probabilitySummary: string;
|
||||
activeMarketScan: MarketScan | null | undefined;
|
||||
}
|
||||
|
||||
export function FutureForecastTodayLayout(props: FutureForecastTodayLayoutProps) {
|
||||
const {
|
||||
locale,
|
||||
isToday,
|
||||
dateStr,
|
||||
detail,
|
||||
currentTempText,
|
||||
weatherSummary,
|
||||
daylightProgress,
|
||||
topObservedTemp,
|
||||
gapToBaseBucket,
|
||||
pathStatus,
|
||||
showDeferredTodaySections,
|
||||
paceView,
|
||||
paceSignalItems,
|
||||
baseCaseBucket,
|
||||
upsideBucket,
|
||||
nextObservationTime,
|
||||
airportMetarAnchor,
|
||||
confirmationRules,
|
||||
invalidationRules,
|
||||
meteorologySignals,
|
||||
modelSummary,
|
||||
probabilityTitle,
|
||||
probabilitySummary,
|
||||
activeMarketScan,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="future-v2-layout">
|
||||
<aside className="future-v2-left">
|
||||
<FutureAnchorStatusCard
|
||||
locale={locale}
|
||||
currentTempText={currentTempText}
|
||||
weatherSummary={weatherSummary}
|
||||
obsTime={detail.current?.obs_time}
|
||||
topObservedTemp={topObservedTemp}
|
||||
tempSymbol={detail.temp_symbol}
|
||||
gapToBaseBucket={gapToBaseBucket}
|
||||
pathStatus={pathStatus}
|
||||
/>
|
||||
|
||||
{showDeferredTodaySections && paceView ? (
|
||||
<FuturePaceCard
|
||||
locale={locale}
|
||||
paceView={paceView}
|
||||
tempSymbol={detail.temp_symbol}
|
||||
signalItems={paceSignalItems}
|
||||
/>
|
||||
) : isToday ? (
|
||||
<FuturePaceLoadingCard locale={locale} />
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
<main className="future-v2-right">
|
||||
<section className="future-modal-section future-v2-main-chart">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Primary view" : "主视图"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US"
|
||||
? "Today's temperature path (anchor obs + models)"
|
||||
: "今日气温路径(锚点观测 + 模型)"}
|
||||
</h3>
|
||||
</div>
|
||||
<FutureTemperaturePathChart dateStr={dateStr} forceToday={isToday} />
|
||||
<div className="future-v2-chart-thresholds">
|
||||
<span>
|
||||
{locale === "en-US" ? "Base" : "基准"} ·{" "}
|
||||
{baseCaseBucket || "--"}
|
||||
</span>
|
||||
<span>
|
||||
{locale === "en-US" ? "Upside" : "上修"} ·{" "}
|
||||
{upsideBucket || "--"}
|
||||
</span>
|
||||
<span>
|
||||
{locale === "en-US" ? "Invalidates at" : "失效观察"} ·{" "}
|
||||
{nextObservationTime}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FutureForecastTodayEvidenceGrid
|
||||
airportMetarAnchor={airportMetarAnchor}
|
||||
confirmationRules={confirmationRules}
|
||||
invalidationRules={invalidationRules}
|
||||
locale={locale}
|
||||
meteorologySignals={meteorologySignals}
|
||||
modelSummary={modelSummary}
|
||||
/>
|
||||
|
||||
<div className="future-modal-grid">
|
||||
<section className="future-modal-section">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Probability read" : "概率判断"}
|
||||
</div>
|
||||
<h3>{probabilityTitle}</h3>
|
||||
</div>
|
||||
<div className="future-text-block" style={{ marginBottom: "12px" }}>
|
||||
{probabilitySummary}
|
||||
</div>
|
||||
<div style={{ position: "relative", minHeight: "120px" }}>
|
||||
<FutureProbabilityPanel
|
||||
detail={detail}
|
||||
targetDate={dateStr}
|
||||
marketScan={activeMarketScan}
|
||||
hideTitle
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className="future-modal-section">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Model layer" : "模型层"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US"
|
||||
? "Model Range & Spread"
|
||||
: "模型区间与分歧"}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="future-text-block" style={{ marginBottom: "12px" }}>
|
||||
{modelSummary}
|
||||
</div>
|
||||
<FutureModelForecastPanel detail={detail} targetDate={dateStr} hideTitle />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import dashboardShellStyles from "./DashboardShell.module.css";
|
||||
import detailChromeStyles from "./DetailPanelChrome.module.css";
|
||||
import detailContentStyles from "./DetailPanelContent.module.css";
|
||||
import detailSectionsStyles from "./DetailPanelSections.module.css";
|
||||
import futureForecastModalStyles from "./FutureForecastModal.module.css";
|
||||
import modalChromeStyles from "./ModalChrome.module.css";
|
||||
import scanTerminalStyles from "./ScanTerminal.module.css";
|
||||
import scanTerminalBoardStyles from "./ScanTerminalBoard.module.css";
|
||||
@@ -41,5 +40,4 @@ export const scanRootClass = clsx(
|
||||
detailContentStyles.root,
|
||||
detailSectionsStyles.root,
|
||||
modalChromeStyles.root,
|
||||
futureForecastModalStyles.root,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user