1099 lines
40 KiB
TypeScript
1099 lines
40 KiB
TypeScript
"use client";
|
||||
|
|
|
|||
|
|
import clsx from "clsx";
|
|||
|
|
import { CSSProperties, useEffect, useMemo, useState } from "react";
|
|||
|
|
import type { Locale } from "@/lib/i18n";
|
|||
|
|
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
|
|||
|
|
import { getFutureModalView } from "@/lib/dashboard-utils";
|
|||
|
|
import { getModelView, getProbabilityView } from "@/lib/model-utils";
|
|||
|
|
import { getTodayPaceView } from "@/lib/pace-utils";
|
|||
|
|
import { dashboardClient } from "@/lib/dashboard-client";
|
|||
|
|
import { getWeatherSummary } from "@/lib/weather-summary-utils";
|
|||
|
|
import {
|
|||
|
|
normalizeObservationSourceCode,
|
|||
|
|
normalizeObservationSourceLabel,
|
|||
|
|
} from "@/lib/source-labels";
|
|||
|
|
import type {
|
|||
|
|
CityDetail,
|
|||
|
|
ForecastModalMode,
|
|||
|
|
LoadingState,
|
|||
|
|
MarketScan,
|
|||
|
|
ProAccessState,
|
|||
|
|
} from "@/lib/dashboard-types";
|
|||
|
|
import { FutureForecastForwardView } from "./FutureForecastForwardView";
|
|||
|
|
import {
|
|||
|
|
FutureModelForecastPanel,
|
|||
|
|
FutureProbabilityPanel,
|
|||
|
|
FutureTemperaturePathChart,
|
|||
|
|
} from "./FutureForecastModalPanels";
|
|||
|
|
import { FutureForecastTodayDecisionBrief } from "./FutureForecastTodayDecisionBrief";
|
|||
|
|
import { FutureForecastTodayEvidenceGrid } from "./FutureForecastTodayEvidenceGrid";
|
|||
|
|
import { FutureForecastModalHeader } from "./FutureForecastModalHeader";
|
|||
|
|
import {
|
|||
|
|
FutureRefreshLock,
|
|||
|
|
FutureSyncStatusStrip,
|
|||
|
|
type FutureSyncStatusItem,
|
|||
|
|
} from "./FutureForecastModalStatus";
|
|||
|
|
import {
|
|||
|
|
FutureAnchorStatusCard,
|
|||
|
|
FuturePaceCard,
|
|||
|
|
FuturePaceLoadingCard,
|
|||
|
|
type FuturePaceSignalItem,
|
|||
|
|
} from "./FutureForecastTodayCards";
|
|||
|
|
import {
|
|||
|
|
TODAY_MARKET_SCAN_AUTO_REFRESH_MS,
|
|||
|
|
clamp,
|
|||
|
|
formatBucketLabel,
|
|||
|
|
formatMarketPercent,
|
|||
|
|
getTrendMetricVisual,
|
|||
|
|
localizedList,
|
|||
|
|
localizedText,
|
|||
|
|
parseBucketBoundaries,
|
|||
|
|
parseClockMinutes,
|
|||
|
|
parseLeadingNumber,
|
|||
|
|
} from "./FutureForecastModal.utils";
|
|||
|
|
|
|||
|
|
type FutureForecastModalControls = {
|
|||
|
|
closeFutureModal: () => void;
|
|||
|
|
forecastModalMode: ForecastModalMode | null;
|
|||
|
|
futureModalDate: string | null;
|
|||
|
|
loadingState: LoadingState;
|
|||
|
|
openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise<void>;
|
|||
|
|
openTodayModal: (forceRefresh?: boolean) => Promise<void>;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export function FutureForecastModalContent({
|
|||
|
|
modal,
|
|||
|
|
proAccess,
|
|||
|
|
locale,
|
|||
|
|
t,
|
|||
|
|
detail,
|
|||
|
|
dateStr,
|
|||
|
|
}: {
|
|||
|
|
modal: FutureForecastModalControls;
|
|||
|
|
proAccess: ProAccessState;
|
|||
|
|
locale: Locale;
|
|||
|
|
t: (key: string, params?: Record<string, string | number>) => string;
|
|||
|
|
detail: CityDetail;
|
|||
|
|
dateStr: string;
|
|||
|
|
}) {
|
|||
|
|
const isPro = proAccess.subscriptionActive;
|
|||
|
|
const isProLoading = proAccess.loading;
|
|||
|
|
const hasModalContext = true;
|
|||
|
|
const [showDeferredTodaySections, setShowDeferredTodaySections] =
|
|||
|
|
useState(false);
|
|||
|
|
const [freshMarketScan, setFreshMarketScan] = useState<MarketScan | null>(
|
|||
|
|
null,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!hasModalContext) {
|
|||
|
|
setShowDeferredTodaySections(false);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setShowDeferredTodaySections(false);
|
|||
|
|
if (typeof window === "undefined") {
|
|||
|
|
setShowDeferredTodaySections(true);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let cancelled = false;
|
|||
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|||
|
|
let idleId: number | null = null;
|
|||
|
|
const reveal = () => {
|
|||
|
|
if (!cancelled) {
|
|||
|
|
setShowDeferredTodaySections(true);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if ("requestIdleCallback" in window) {
|
|||
|
|
idleId = window.requestIdleCallback(reveal, { timeout: 600 });
|
|||
|
|
} else {
|
|||
|
|
timeoutId = setTimeout(reveal, 120);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return () => {
|
|||
|
|
cancelled = true;
|
|||
|
|
if (idleId != null && "cancelIdleCallback" in window) {
|
|||
|
|
window.cancelIdleCallback(idleId);
|
|||
|
|
}
|
|||
|
|
if (timeoutId != null) {
|
|||
|
|
clearTimeout(timeoutId);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}, [dateStr, detail, hasModalContext]);
|
|||
|
|
|
|||
|
|
const isToday =
|
|||
|
|
modal.forecastModalMode === "today" ||
|
|||
|
|
(modal.forecastModalMode == null && dateStr === detail?.local_date);
|
|||
|
|
const detailDepth = detail?.detail_depth || "full";
|
|||
|
|
const isFullDetailReady = detailDepth === "full";
|
|||
|
|
const isStructureSyncing =
|
|||
|
|
modal.loadingState.futureDeep || !isFullDetailReady;
|
|||
|
|
const isAnyLayerSyncing = isStructureSyncing;
|
|||
|
|
const isTodayBlockingRefresh = isToday && isStructureSyncing;
|
|||
|
|
const activeMarketScan = freshMarketScan || detail?.market_scan || null;
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
setFreshMarketScan(null);
|
|||
|
|
if (!hasModalContext || !isToday || !isFullDetailReady || !isPro) return;
|
|||
|
|
const cityName = String(detail?.name || detail?.display_name || "").trim();
|
|||
|
|
if (!cityName || !dateStr) return;
|
|||
|
|
|
|||
|
|
let cancelled = false;
|
|||
|
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|||
|
|
|
|||
|
|
const refreshMarketScan = () => {
|
|||
|
|
dashboardClient
|
|||
|
|
.getCityMarketScan(cityName, {
|
|||
|
|
force: false,
|
|||
|
|
lite: false,
|
|||
|
|
marketSlug: detail?.market_scan?.primary_market?.slug || null,
|
|||
|
|
targetDate: dateStr,
|
|||
|
|
})
|
|||
|
|
.then((payload) => {
|
|||
|
|
if (cancelled) return;
|
|||
|
|
setFreshMarketScan(payload.market_scan || null);
|
|||
|
|
})
|
|||
|
|
.catch(() => {
|
|||
|
|
if (!cancelled) {
|
|||
|
|
setFreshMarketScan(null);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
refreshMarketScan();
|
|||
|
|
intervalId = setInterval(() => {
|
|||
|
|
if (
|
|||
|
|
typeof document !== "undefined" &&
|
|||
|
|
document.visibilityState === "hidden"
|
|||
|
|
) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
refreshMarketScan();
|
|||
|
|
}, TODAY_MARKET_SCAN_AUTO_REFRESH_MS);
|
|||
|
|
|
|||
|
|
return () => {
|
|||
|
|
cancelled = true;
|
|||
|
|
if (intervalId != null) {
|
|||
|
|
clearInterval(intervalId);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}, [
|
|||
|
|
dateStr,
|
|||
|
|
detail?.display_name,
|
|||
|
|
detail?.local_date,
|
|||
|
|
detail?.market_scan?.primary_market?.slug,
|
|||
|
|
detail?.name,
|
|||
|
|
detail?.updated_at,
|
|||
|
|
hasModalContext,
|
|||
|
|
isFullDetailReady,
|
|||
|
|
isPro,
|
|||
|
|
isToday,
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const view = getFutureModalView(detail, dateStr, locale);
|
|||
|
|
const scorePosition = `${50 + view.front.score / 2}%`;
|
|||
|
|
const barStyle = {
|
|||
|
|
"--score-position": scorePosition,
|
|||
|
|
} as CSSProperties & { "--score-position": string };
|
|||
|
|
const weatherSummary = getWeatherSummary(detail, locale);
|
|||
|
|
const paceView = useMemo(
|
|||
|
|
() =>
|
|||
|
|
isToday && showDeferredTodaySections
|
|||
|
|
? getTodayPaceView(detail, locale)
|
|||
|
|
: null,
|
|||
|
|
[detail, isToday, locale, showDeferredTodaySections],
|
|||
|
|
);
|
|||
|
|
const probabilityView = useMemo(
|
|||
|
|
() => getProbabilityView(detail, dateStr),
|
|||
|
|
[dateStr, detail],
|
|||
|
|
);
|
|||
|
|
const modelView = useMemo(
|
|||
|
|
() => getModelView(detail, dateStr),
|
|||
|
|
[dateStr, detail],
|
|||
|
|
);
|
|||
|
|
const probabilityEngineKey = String(probabilityView?.engine || "")
|
|||
|
|
.trim()
|
|||
|
|
.toLowerCase();
|
|||
|
|
const probabilityCalibrationMode = String(
|
|||
|
|
probabilityView?.calibrationMode || "",
|
|||
|
|
)
|
|||
|
|
.trim()
|
|||
|
|
.toLowerCase();
|
|||
|
|
const hasLgbmProbability = useMemo(
|
|||
|
|
() =>
|
|||
|
|
Object.keys(modelView?.models || {}).some((name) =>
|
|||
|
|
String(name || "")
|
|||
|
|
.toLowerCase()
|
|||
|
|
.replace(/[\s_/-]/g, "")
|
|||
|
|
.includes("lgbm"),
|
|||
|
|
),
|
|||
|
|
[modelView],
|
|||
|
|
);
|
|||
|
|
const hasEmosProbability =
|
|||
|
|
probabilityEngineKey === "emos" ||
|
|||
|
|
probabilityCalibrationMode.includes("emos");
|
|||
|
|
const probabilityEngineLabel = hasLgbmProbability
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "LGBM"
|
|||
|
|
: "LGBM"
|
|||
|
|
: hasEmosProbability
|
|||
|
|
? "EMOS"
|
|||
|
|
: locale === "en-US"
|
|||
|
|
? "Calibrated model"
|
|||
|
|
: "校准模型";
|
|||
|
|
const probabilityTitle = hasLgbmProbability
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "LGBM-Calibrated Probability"
|
|||
|
|
: "LGBM 校准概率"
|
|||
|
|
: hasEmosProbability
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "EMOS-Calibrated Probability"
|
|||
|
|
: "EMOS 校准概率"
|
|||
|
|
: locale === "en-US"
|
|||
|
|
? "Calibrated Model Probability"
|
|||
|
|
: "校准模型概率";
|
|||
|
|
const topProbabilityBucket = useMemo(() => {
|
|||
|
|
const buckets = Array.isArray(probabilityView?.probabilities)
|
|||
|
|
? probabilityView.probabilities
|
|||
|
|
: [];
|
|||
|
|
return [...buckets]
|
|||
|
|
.filter((bucket) => Number.isFinite(Number(bucket?.probability)))
|
|||
|
|
.sort((a, b) => Number(b?.probability) - Number(a?.probability))[0];
|
|||
|
|
}, [probabilityView]);
|
|||
|
|
const modelSpreadView = useMemo(() => {
|
|||
|
|
const values = Object.values(modelView?.models || {})
|
|||
|
|
.map((value) => Number(value))
|
|||
|
|
.filter((value) => Number.isFinite(value));
|
|||
|
|
if (!values.length) return null;
|
|||
|
|
const min = Math.min(...values);
|
|||
|
|
const max = Math.max(...values);
|
|||
|
|
const spread = max - min;
|
|||
|
|
return {
|
|||
|
|
count: values.length,
|
|||
|
|
max,
|
|||
|
|
min,
|
|||
|
|
spread,
|
|||
|
|
};
|
|||
|
|
}, [modelView]);
|
|||
|
|
const boundaryRiskView = useMemo(() => {
|
|||
|
|
if (!showDeferredTodaySections) return null;
|
|||
|
|
if (!isToday || !paceView) return null;
|
|||
|
|
const selectedBucket = topProbabilityBucket || null;
|
|||
|
|
const bounds = parseBucketBoundaries(selectedBucket);
|
|||
|
|
if (!bounds) return null;
|
|||
|
|
const projected =
|
|||
|
|
paceView.paceAdjustedHigh ??
|
|||
|
|
(detail.deb?.prediction != null ? Number(detail.deb.prediction) : null);
|
|||
|
|
if (projected == null || !Number.isFinite(projected)) return null;
|
|||
|
|
|
|||
|
|
const distances = [bounds.lower, bounds.upper]
|
|||
|
|
.filter(
|
|||
|
|
(value): value is number => value != null && Number.isFinite(value),
|
|||
|
|
)
|
|||
|
|
.map((value) => ({
|
|||
|
|
boundary: value,
|
|||
|
|
gap: Math.abs(projected - value),
|
|||
|
|
}))
|
|||
|
|
.sort((a, b) => a.gap - b.gap);
|
|||
|
|
if (!distances.length) return null;
|
|||
|
|
|
|||
|
|
const nearest = distances[0];
|
|||
|
|
const tone =
|
|||
|
|
nearest.gap <= 0.4 ? "amber" : nearest.gap <= 0.8 ? "blue" : "cyan";
|
|||
|
|
const status =
|
|||
|
|
nearest.gap <= 0.4
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "High boundary risk"
|
|||
|
|
: "边界风险高"
|
|||
|
|
: nearest.gap <= 0.8
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "Watch boundary"
|
|||
|
|
: "边界需观察"
|
|||
|
|
: locale === "en-US"
|
|||
|
|
? "Boundary buffer"
|
|||
|
|
: "边界缓冲";
|
|||
|
|
const note =
|
|||
|
|
locale === "en-US"
|
|||
|
|
? `${projected.toFixed(1)}${detail.temp_symbol} is ${nearest.gap.toFixed(1)}${detail.temp_symbol} from the nearest boundary ${nearest.boundary.toFixed(1)}°C.`
|
|||
|
|
: `${projected.toFixed(1)}${detail.temp_symbol} 距最近边界 ${nearest.boundary.toFixed(1)}°C 还有 ${nearest.gap.toFixed(1)}${detail.temp_symbol}。`;
|
|||
|
|
return {
|
|||
|
|
label: locale === "en-US" ? "Boundary risk" : "边界风险",
|
|||
|
|
note,
|
|||
|
|
status,
|
|||
|
|
tone,
|
|||
|
|
value: `${nearest.gap.toFixed(1)}${detail.temp_symbol}`,
|
|||
|
|
};
|
|||
|
|
}, [
|
|||
|
|
detail.deb?.prediction,
|
|||
|
|
detail.temp_symbol,
|
|||
|
|
isToday,
|
|||
|
|
locale,
|
|||
|
|
paceView,
|
|||
|
|
showDeferredTodaySections,
|
|||
|
|
topProbabilityBucket,
|
|||
|
|
]);
|
|||
|
|
const peakWindowStateView = useMemo(() => {
|
|||
|
|
if (!showDeferredTodaySections) return null;
|
|||
|
|
if (!isToday || !paceView) return null;
|
|||
|
|
const firstHour = Number(detail.peak?.first_h);
|
|||
|
|
const lastHour = Number(detail.peak?.last_h);
|
|||
|
|
if (
|
|||
|
|
!Number.isFinite(firstHour) ||
|
|||
|
|
!Number.isFinite(lastHour) ||
|
|||
|
|
firstHour < 0 ||
|
|||
|
|
lastHour < firstHour
|
|||
|
|
) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const currentMinutes = parseClockMinutes(detail.local_time);
|
|||
|
|
const startMinutes = firstHour * 60;
|
|||
|
|
const endMinutes = (lastHour + 1) * 60;
|
|||
|
|
let status = locale === "en-US" ? "Awaiting peak" : "未进入峰值";
|
|||
|
|
let tone: "amber" | "blue" | "cyan" = "blue";
|
|||
|
|
if (currentMinutes != null && currentMinutes >= endMinutes) {
|
|||
|
|
status = locale === "en-US" ? "Past peak" : "已过峰值";
|
|||
|
|
tone = "cyan";
|
|||
|
|
} else if (currentMinutes != null && currentMinutes >= startMinutes) {
|
|||
|
|
status = locale === "en-US" ? "Peak window live" : "峰值窗口进行中";
|
|||
|
|
tone = "amber";
|
|||
|
|
}
|
|||
|
|
const note =
|
|||
|
|
locale === "en-US"
|
|||
|
|
? `Primary peak window ${paceView.peakWindowText}.`
|
|||
|
|
: `核心峰值窗口 ${paceView.peakWindowText}。`;
|
|||
|
|
return {
|
|||
|
|
label: locale === "en-US" ? "Peak window" : "峰值窗口状态",
|
|||
|
|
note,
|
|||
|
|
status,
|
|||
|
|
tone,
|
|||
|
|
value: paceView.peakWindowText,
|
|||
|
|
};
|
|||
|
|
}, [
|
|||
|
|
detail.local_time,
|
|||
|
|
detail.peak?.first_h,
|
|||
|
|
detail.peak?.last_h,
|
|||
|
|
isToday,
|
|||
|
|
locale,
|
|||
|
|
paceView,
|
|||
|
|
showDeferredTodaySections,
|
|||
|
|
]);
|
|||
|
|
const networkLeadView = useMemo(() => {
|
|||
|
|
if (!showDeferredTodaySections) return null;
|
|||
|
|
if (!isToday) return null;
|
|||
|
|
const delta = Number(detail.airport_vs_network_delta);
|
|||
|
|
const leadSignal = detail.network_lead_signal;
|
|||
|
|
if (!Number.isFinite(delta)) return null;
|
|||
|
|
const leaderLabel =
|
|||
|
|
String(leadSignal?.leader_station_label || "").trim() ||
|
|||
|
|
String(leadSignal?.leader_station_code || "").trim();
|
|||
|
|
const leaderSyncStatus = String(leadSignal?.leader_sync_status || "")
|
|||
|
|
.trim()
|
|||
|
|
.toLowerCase();
|
|||
|
|
const leaderSyncDelta = Number(
|
|||
|
|
leadSignal?.leader_time_delta_vs_anchor_minutes,
|
|||
|
|
);
|
|||
|
|
const syncNote =
|
|||
|
|
leaderSyncStatus === "near_realtime" || leaderSyncStatus === "lagged"
|
|||
|
|
? Number.isFinite(leaderSyncDelta)
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? ` Timing offset versus the airport anchor is about ${Math.round(leaderSyncDelta)} minutes.`
|
|||
|
|
: ` 与机场锚点存在约 ${Math.round(leaderSyncDelta)} 分钟时间差。`
|
|||
|
|
: locale === "en-US"
|
|||
|
|
? " Nearby observations are not fully synchronized."
|
|||
|
|
: " 周边观测并非完全同步。"
|
|||
|
|
: leaderSyncStatus === "unknown"
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? " Nearby station timing is not fully verified."
|
|||
|
|
: " 周边站观测时间尚未完全校验。"
|
|||
|
|
: "";
|
|||
|
|
const absDelta = Math.abs(delta);
|
|||
|
|
const status =
|
|||
|
|
delta <= -0.4
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "Airport trailing"
|
|||
|
|
: "机场落后"
|
|||
|
|
: delta >= 0.4
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "Airport leading"
|
|||
|
|
: "机场领先"
|
|||
|
|
: locale === "en-US"
|
|||
|
|
? "Tracking network"
|
|||
|
|
: "与站网齐平";
|
|||
|
|
const tone = delta <= -0.4 ? "amber" : delta >= 0.4 ? "cyan" : "blue";
|
|||
|
|
const note =
|
|||
|
|
delta <= -0.4
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} cooler than the nearby official network${leaderLabel ? `, led by ${leaderLabel}` : ""}.${syncNote}`
|
|||
|
|
: `机场主站当前比周边官方站网低 ${absDelta.toFixed(1)}${detail.temp_symbol}${leaderLabel ? `,领先点位是 ${leaderLabel}` : ""}。${syncNote}`
|
|||
|
|
: delta >= 0.4
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} hotter than the nearby official network.${syncNote}`
|
|||
|
|
: `机场主站当前比周边官方站网高 ${absDelta.toFixed(1)}${detail.temp_symbol}。${syncNote}`
|
|||
|
|
: locale === "en-US"
|
|||
|
|
? "Airport anchor and nearby official network are broadly aligned."
|
|||
|
|
: "机场主站与周边官方站网当前大体齐平。";
|
|||
|
|
return {
|
|||
|
|
label: locale === "en-US" ? "Airport vs network" : "机场 vs 周边站",
|
|||
|
|
note,
|
|||
|
|
status,
|
|||
|
|
tone,
|
|||
|
|
value: `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`,
|
|||
|
|
};
|
|||
|
|
}, [
|
|||
|
|
detail.airport_vs_network_delta,
|
|||
|
|
detail.network_lead_signal,
|
|||
|
|
detail.temp_symbol,
|
|||
|
|
isToday,
|
|||
|
|
locale,
|
|||
|
|
showDeferredTodaySections,
|
|||
|
|
]);
|
|||
|
|
const paceSignalItems = useMemo(
|
|||
|
|
() =>
|
|||
|
|
[boundaryRiskView, peakWindowStateView, networkLeadView]
|
|||
|
|
.filter((item) => item != null)
|
|||
|
|
.map((item) => ({
|
|||
|
|
label: item.label,
|
|||
|
|
note: item.note,
|
|||
|
|
status: item.status,
|
|||
|
|
tone: item.tone,
|
|||
|
|
value: item.value,
|
|||
|
|
})) as FuturePaceSignalItem[],
|
|||
|
|
[boundaryRiskView, networkLeadView, peakWindowStateView],
|
|||
|
|
);
|
|||
|
|
const isNoaaSettlement =
|
|||
|
|
detail.current?.settlement_source === "noaa" ||
|
|||
|
|
detail.current?.settlement_source_label === "NOAA";
|
|||
|
|
const noaaStationCode = String(
|
|||
|
|
detail.current?.station_code || detail.risk?.icao || "NOAA",
|
|||
|
|
)
|
|||
|
|
.trim()
|
|||
|
|
.toUpperCase();
|
|||
|
|
const noaaStationName =
|
|||
|
|
String(detail.current?.station_name || "").trim() ||
|
|||
|
|
String(detail.risk?.airport || "").trim() ||
|
|||
|
|
noaaStationCode;
|
|||
|
|
const hottestBucketLabel = formatBucketLabel(topProbabilityBucket);
|
|||
|
|
const probabilitySummary = (() => {
|
|||
|
|
if (!topProbabilityBucket) {
|
|||
|
|
return locale === "en-US"
|
|||
|
|
? "Probability mass is still too dispersed; avoid over-reading a single bracket."
|
|||
|
|
: "当前概率还比较分散,不要只盯单一区间。";
|
|||
|
|
}
|
|||
|
|
const bucketLabel = formatBucketLabel(topProbabilityBucket);
|
|||
|
|
const bucketProb = formatMarketPercent(topProbabilityBucket.probability);
|
|||
|
|
if (!isToday) {
|
|||
|
|
return locale === "en-US"
|
|||
|
|
? `Target-day model probability reference puts the leading bucket at ${bucketLabel} (${bucketProb}). EMOS is reserved for intraday analysis after live anchor observations arrive.`
|
|||
|
|
: `目标日模型概率参考显示领先温度桶为 ${bucketLabel}(${bucketProb})。EMOS 仅用于有实时锚点观测后的日内分析。`;
|
|||
|
|
}
|
|||
|
|
if (hasLgbmProbability) {
|
|||
|
|
return locale === "en-US"
|
|||
|
|
? `LGBM-calibrated read puts the leading bucket at ${bucketLabel} (${bucketProb}). Treat this as the base case, not the final settlement.`
|
|||
|
|
: `LGBM 校准后领先温度桶为 ${bucketLabel}(${bucketProb})。可作为基准情形,但不要直接等同于最终结算。`;
|
|||
|
|
}
|
|||
|
|
if (hasEmosProbability) {
|
|||
|
|
return locale === "en-US"
|
|||
|
|
? `EMOS-calibrated probability puts the leading bucket at ${bucketLabel} (${bucketProb}). It is the primary calibrated probability layer, not the final settlement.`
|
|||
|
|
: `EMOS 校准概率显示领先温度桶为 ${bucketLabel}(${bucketProb})。这是当前主概率层,但不要直接等同于最终结算。`;
|
|||
|
|
}
|
|||
|
|
return locale === "en-US"
|
|||
|
|
? `Calibrated model probability puts the leading bucket at ${bucketLabel} (${bucketProb}). Treat this as the base case, not the final settlement.`
|
|||
|
|
: `校准模型概率显示领先温度桶为 ${bucketLabel}(${bucketProb})。可作为基准情形,但不要直接等同于最终结算。`;
|
|||
|
|
})();
|
|||
|
|
const modelSummary = (() => {
|
|||
|
|
if (!modelSpreadView) {
|
|||
|
|
return locale === "en-US"
|
|||
|
|
? "Model spread is unavailable right now."
|
|||
|
|
: "当前拿不到可用的模型分歧。";
|
|||
|
|
}
|
|||
|
|
const modelEntries = Object.entries(modelView?.models || {}).filter(
|
|||
|
|
([, value]) =>
|
|||
|
|
value !== null && value !== undefined && Number.isFinite(Number(value)),
|
|||
|
|
);
|
|||
|
|
if (modelEntries.length === 1) {
|
|||
|
|
const [singleModelName, singleModelValue] = modelEntries[0];
|
|||
|
|
return locale === "en-US"
|
|||
|
|
? `Only ${singleModelName} is available right now at ${Number(singleModelValue).toFixed(1)}${detail.temp_symbol}; multi-model spread is temporarily unavailable.`
|
|||
|
|
: `当前只收到 ${singleModelName} ${Number(singleModelValue).toFixed(1)}${detail.temp_symbol},其他多模型暂未回传,所以这里先不判断模型分歧。`;
|
|||
|
|
}
|
|||
|
|
return locale === "en-US"
|
|||
|
|
? `Model range runs from ${modelSpreadView.min.toFixed(1)}${detail.temp_symbol} to ${modelSpreadView.max.toFixed(1)}${detail.temp_symbol}; spread ${modelSpreadView.spread.toFixed(1)}${detail.temp_symbol}.`
|
|||
|
|
: `当前模型区间在 ${modelSpreadView.min.toFixed(1)}${detail.temp_symbol} 到 ${modelSpreadView.max.toFixed(1)}${detail.temp_symbol},分歧 ${modelSpreadView.spread.toFixed(1)}${detail.temp_symbol}。`;
|
|||
|
|
})();
|
|||
|
|
const upperAirSignal = detail.vertical_profile_signal || {};
|
|||
|
|
const tafSignal = detail.taf?.signal || {};
|
|||
|
|
const upperAirCue = useMemo(() => {
|
|||
|
|
if (!showDeferredTodaySections) return null;
|
|||
|
|
if (!isToday || (!upperAirSignal.source && !tafSignal.available))
|
|||
|
|
return null;
|
|||
|
|
|
|||
|
|
const setup = String(
|
|||
|
|
upperAirSignal.heating_setup || "neutral",
|
|||
|
|
).toLowerCase();
|
|||
|
|
const tafSuppression = String(
|
|||
|
|
tafSignal.suppression_level || "low",
|
|||
|
|
).toLowerCase();
|
|||
|
|
const tafDisruption = String(
|
|||
|
|
tafSignal.disruption_level || "low",
|
|||
|
|
).toLowerCase();
|
|||
|
|
const reasons: string[] = [];
|
|||
|
|
let score = 0;
|
|||
|
|
|
|||
|
|
if (setup === "supportive") {
|
|||
|
|
score += 2;
|
|||
|
|
reasons.push(
|
|||
|
|
locale === "en-US"
|
|||
|
|
? "upper-air structure still supports daytime heating"
|
|||
|
|
: "高空结构仍偏支持白天冲高",
|
|||
|
|
);
|
|||
|
|
} else if (setup === "suppressed") {
|
|||
|
|
score -= 2;
|
|||
|
|
reasons.push(
|
|||
|
|
locale === "en-US"
|
|||
|
|
? "upper-air structure still leans toward capping the peak"
|
|||
|
|
: "高空结构更偏向压住峰值",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (tafSuppression === "high") {
|
|||
|
|
score -= 2;
|
|||
|
|
reasons.push(
|
|||
|
|
locale === "en-US"
|
|||
|
|
? "TAF flags meaningful cloud/rain suppression near the peak window"
|
|||
|
|
: "TAF 在峰值窗口提示云雨压温风险偏高",
|
|||
|
|
);
|
|||
|
|
} else if (tafSuppression === "medium") {
|
|||
|
|
score -= 1;
|
|||
|
|
reasons.push(
|
|||
|
|
locale === "en-US"
|
|||
|
|
? "TAF keeps some cloud/rain suppression risk on the table"
|
|||
|
|
: "TAF 仍提示一定的云雨压温风险",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (tafDisruption === "high") {
|
|||
|
|
score -= 1;
|
|||
|
|
reasons.push(
|
|||
|
|
locale === "en-US"
|
|||
|
|
? "TAF also suggests a noisier afternoon regime"
|
|||
|
|
: "TAF 还提示午后扰动偏强",
|
|||
|
|
);
|
|||
|
|
} else if (tafDisruption === "medium") {
|
|||
|
|
score -= 0.5;
|
|||
|
|
reasons.push(
|
|||
|
|
locale === "en-US"
|
|||
|
|
? "TAF keeps some afternoon timing noise in play"
|
|||
|
|
: "TAF 提示午后仍可能有时段性扰动",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (score >= 1.5) {
|
|||
|
|
return {
|
|||
|
|
summary:
|
|||
|
|
locale === "en-US"
|
|||
|
|
? "The combined upper-air and TAF read still leans warmer. Do not fade lower buckets too early."
|
|||
|
|
: "高空和 TAF 两层信号合并后仍偏暖侧,不宜过早做更低温区间。",
|
|||
|
|
note:
|
|||
|
|
locale === "en-US"
|
|||
|
|
? `${reasons.slice(0, 2).join("; ")}.`
|
|||
|
|
: `${reasons.slice(0, 2).join(";")}。`,
|
|||
|
|
tone: "warm",
|
|||
|
|
value: locale === "en-US" ? "Lean warmer" : "偏暖侧",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (score <= -1.5) {
|
|||
|
|
return {
|
|||
|
|
summary:
|
|||
|
|
locale === "en-US"
|
|||
|
|
? "The combined upper-air and TAF read leans more defensive. Be more careful chasing higher buckets."
|
|||
|
|
: "高空和 TAF 两层信号合并后更偏防守,追更高温区间要更谨慎。",
|
|||
|
|
note:
|
|||
|
|
locale === "en-US"
|
|||
|
|
? `${reasons.slice(0, 2).join("; ")}.`
|
|||
|
|
: `${reasons.slice(0, 2).join(";")}。`,
|
|||
|
|
tone: "cold",
|
|||
|
|
value: locale === "en-US" ? "Lean cautious" : "偏谨慎",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
summary:
|
|||
|
|
locale === "en-US"
|
|||
|
|
? "The combined upper-air and TAF read is mixed. Let surface structure decide before taking a side."
|
|||
|
|
: "高空和 TAF 两层信号目前偏混合,先看近地面结构变化,不急着站边。",
|
|||
|
|
note:
|
|||
|
|
locale === "en-US"
|
|||
|
|
? `${reasons.slice(0, 2).join("; ") || "No clean edge from the upper-air layer alone"}.`
|
|||
|
|
: `${reasons.slice(0, 2).join(";") || "单看高空层还没有干净的交易边"}。`,
|
|||
|
|
tone: "",
|
|||
|
|
value: locale === "en-US" ? "Wait / confirm" : "先观察",
|
|||
|
|
};
|
|||
|
|
}, [
|
|||
|
|
tafSignal.available,
|
|||
|
|
tafSignal.disruption_level,
|
|||
|
|
tafSignal.suppression_level,
|
|||
|
|
isToday,
|
|||
|
|
locale,
|
|||
|
|
upperAirSignal.heating_setup,
|
|||
|
|
upperAirSignal.source,
|
|||
|
|
showDeferredTodaySections,
|
|||
|
|
]);
|
|||
|
|
const topObservedTemp =
|
|||
|
|
detail.current?.max_so_far != null
|
|||
|
|
? detail.current.max_so_far
|
|||
|
|
: detail.current?.temp;
|
|||
|
|
const currentTempText =
|
|||
|
|
detail.current?.temp != null
|
|||
|
|
? `${detail.current.temp}${detail.temp_symbol}`
|
|||
|
|
: "--";
|
|||
|
|
const daylightProgress = (() => {
|
|||
|
|
const now = parseClockMinutes(detail.current?.obs_time);
|
|||
|
|
const sunrise = parseClockMinutes(detail.forecast?.sunrise);
|
|||
|
|
const sunset = parseClockMinutes(detail.forecast?.sunset);
|
|||
|
|
if (now == null || sunrise == null || sunset == null || sunset <= sunrise) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const percent = clamp(((now - sunrise) / (sunset - sunrise)) * 100, 0, 100);
|
|||
|
|
const phase =
|
|||
|
|
now < sunrise ? "夜间" : now > sunset ? "已日落" : "白昼进行中";
|
|||
|
|
return {
|
|||
|
|
phase,
|
|||
|
|
percent,
|
|||
|
|
};
|
|||
|
|
})();
|
|||
|
|
const displayedUpperAirSummary = showDeferredTodaySections
|
|||
|
|
? upperAirCue?.summary || view.front.upperAirSummary
|
|||
|
|
: "";
|
|||
|
|
const displayedUpperAirMetrics = showDeferredTodaySections
|
|||
|
|
? (view.front.upperAirMetrics || []).map((metric, index) =>
|
|||
|
|
index === 0 &&
|
|||
|
|
(metric.label === "Trade cue" || metric.label === "交易动作") &&
|
|||
|
|
upperAirCue
|
|||
|
|
? {
|
|||
|
|
...metric,
|
|||
|
|
note: upperAirCue.note,
|
|||
|
|
tone: upperAirCue.tone,
|
|||
|
|
value: upperAirCue.value,
|
|||
|
|
}
|
|||
|
|
: metric,
|
|||
|
|
)
|
|||
|
|
: [];
|
|||
|
|
const localizedAiCommentaryLines = useMemo(() => {
|
|||
|
|
if (!showDeferredTodaySections) return [] as string[];
|
|||
|
|
const commentary = detail.dynamic_commentary || {};
|
|||
|
|
const headline = String(
|
|||
|
|
locale === "en-US"
|
|||
|
|
? commentary.headline_en || ""
|
|||
|
|
: commentary.headline_zh || "",
|
|||
|
|
).trim();
|
|||
|
|
const bullets = (
|
|||
|
|
locale === "en-US" ? commentary.bullets_en : commentary.bullets_zh
|
|||
|
|
) as string[] | null | undefined;
|
|||
|
|
const cleanedBullets = Array.isArray(bullets)
|
|||
|
|
? bullets.map((item) => String(item || "").trim()).filter(Boolean)
|
|||
|
|
: [];
|
|||
|
|
return [headline, ...cleanedBullets].filter(Boolean).slice(0, 3);
|
|||
|
|
}, [detail.dynamic_commentary, locale, showDeferredTodaySections]);
|
|||
|
|
const todayTradeSummaryLines = useMemo(() => {
|
|||
|
|
if (!showDeferredTodaySections) return [] as string[];
|
|||
|
|
if (!isToday) return [] as string[];
|
|||
|
|
if (localizedAiCommentaryLines.length > 0) {
|
|||
|
|
return localizedAiCommentaryLines;
|
|||
|
|
}
|
|||
|
|
const lines: string[] = [];
|
|||
|
|
if (paceView) {
|
|||
|
|
const headline =
|
|||
|
|
paceView.biasTone === "warm"
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? `Pace is running hot by ${paceView.deltaText}; the day high still leans above the base curve.`
|
|||
|
|
: `节奏偏热 ${paceView.deltaText},日高仍偏向落在基础曲线之上。`
|
|||
|
|
: paceView.biasTone === "cold"
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? `Pace is trailing by ${paceView.deltaText}; chasing higher buckets needs caution.`
|
|||
|
|
: `节奏落后 ${paceView.deltaText},继续追更高温区间要更谨慎。`
|
|||
|
|
: locale === "en-US"
|
|||
|
|
? "Pace is still on curve; the next move depends on the peak-window push."
|
|||
|
|
: "节奏目前贴着曲线走,下一步主要看峰值窗口还有没有上冲。";
|
|||
|
|
lines.push(headline);
|
|||
|
|
}
|
|||
|
|
if (boundaryRiskView) {
|
|||
|
|
lines.push(
|
|||
|
|
locale === "en-US"
|
|||
|
|
? `${boundaryRiskView.label}: ${boundaryRiskView.note}`
|
|||
|
|
: `${boundaryRiskView.label}:${boundaryRiskView.note}`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if (networkLeadView) {
|
|||
|
|
lines.push(
|
|||
|
|
locale === "en-US"
|
|||
|
|
? `${networkLeadView.label}: ${networkLeadView.note}`
|
|||
|
|
: `${networkLeadView.label}:${networkLeadView.note}`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
return lines.slice(0, 3);
|
|||
|
|
}, [
|
|||
|
|
boundaryRiskView,
|
|||
|
|
isToday,
|
|||
|
|
locale,
|
|||
|
|
localizedAiCommentaryLines,
|
|||
|
|
networkLeadView,
|
|||
|
|
paceView,
|
|||
|
|
showDeferredTodaySections,
|
|||
|
|
]);
|
|||
|
|
const intradayMeteorology = detail.intraday_meteorology || {};
|
|||
|
|
const meteorologySignals = Array.isArray(
|
|||
|
|
intradayMeteorology.signal_contributions,
|
|||
|
|
)
|
|||
|
|
? intradayMeteorology.signal_contributions
|
|||
|
|
: [];
|
|||
|
|
const invalidationRules = localizedList(
|
|||
|
|
locale,
|
|||
|
|
intradayMeteorology.invalidation_rules,
|
|||
|
|
intradayMeteorology.invalidation_rules_en,
|
|||
|
|
);
|
|||
|
|
const confirmationRules = localizedList(
|
|||
|
|
locale,
|
|||
|
|
intradayMeteorology.confirmation_rules,
|
|||
|
|
intradayMeteorology.confirmation_rules_en,
|
|||
|
|
);
|
|||
|
|
const meteorologyHeadline =
|
|||
|
|
localizedText(
|
|||
|
|
locale,
|
|||
|
|
intradayMeteorology.headline,
|
|||
|
|
intradayMeteorology.headline_en,
|
|||
|
|
) ||
|
|||
|
|
todayTradeSummaryLines[0] ||
|
|||
|
|
(locale === "en-US"
|
|||
|
|
? "Intraday meteorology layers are still syncing; use the next observation as the anchor."
|
|||
|
|
: "关键日内气象层仍在同步,先以下一次观测作为判断锚点。");
|
|||
|
|
const baseCaseBucket =
|
|||
|
|
String(intradayMeteorology.base_case_bucket || "").trim() ||
|
|||
|
|
formatBucketLabel(topProbabilityBucket);
|
|||
|
|
const nextObservationTime =
|
|||
|
|
String(intradayMeteorology.next_observation_time || "").trim() || "--";
|
|||
|
|
const baseBucketNumber = parseLeadingNumber(baseCaseBucket);
|
|||
|
|
const referenceObservedTemp =
|
|||
|
|
topObservedTemp != null && Number.isFinite(Number(topObservedTemp))
|
|||
|
|
? Number(topObservedTemp)
|
|||
|
|
: detail.current?.temp != null
|
|||
|
|
? Number(detail.current.temp)
|
|||
|
|
: null;
|
|||
|
|
const gapToBaseBucket =
|
|||
|
|
baseBucketNumber != null && referenceObservedTemp != null
|
|||
|
|
? Math.max(0, baseBucketNumber - referenceObservedTemp)
|
|||
|
|
: null;
|
|||
|
|
const pathStatus =
|
|||
|
|
gapToBaseBucket == null
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "Awaiting anchor"
|
|||
|
|
: "等待锚点"
|
|||
|
|
: gapToBaseBucket <= 0.05
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "Base path touched"
|
|||
|
|
: "基准路径已触达"
|
|||
|
|
: gapToBaseBucket <= 1.0
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "Base path open"
|
|||
|
|
: "基准路径开放"
|
|||
|
|
: locale === "en-US"
|
|||
|
|
? "Needs peak push"
|
|||
|
|
: "需要峰值推动";
|
|||
|
|
const peakWindowText =
|
|||
|
|
String(intradayMeteorology.peak_window || "").trim() ||
|
|||
|
|
paceView?.peakWindowText ||
|
|||
|
|
"--";
|
|||
|
|
const settlementSourceCode = normalizeObservationSourceCode(
|
|||
|
|
detail.current?.settlement_source || "",
|
|||
|
|
);
|
|||
|
|
const settlementStationCode = String(
|
|||
|
|
detail.current?.station_code || detail.risk?.icao || "",
|
|||
|
|
)
|
|||
|
|
.trim()
|
|||
|
|
.toUpperCase();
|
|||
|
|
const settlementStationName =
|
|||
|
|
String(detail.current?.station_name || detail.risk?.airport || "").trim() ||
|
|||
|
|
settlementStationCode ||
|
|||
|
|
(locale === "en-US" ? "Anchor station" : "锚点站");
|
|||
|
|
const airportMetarAnchor =
|
|||
|
|
settlementSourceCode === "metar" ||
|
|||
|
|
Boolean(settlementStationCode && /^[A-Z]{4}$/.test(settlementStationCode));
|
|||
|
|
const anchorSourceLabel = airportMetarAnchor
|
|||
|
|
? settlementStationCode
|
|||
|
|
? `${settlementStationCode} METAR`
|
|||
|
|
: "METAR"
|
|||
|
|
: normalizeObservationSourceLabel(
|
|||
|
|
detail.current?.settlement_source_label ||
|
|||
|
|
detail.current?.settlement_source,
|
|||
|
|
locale === "en-US" ? "Official observation" : "官方观测",
|
|||
|
|
);
|
|||
|
|
const anchorRuleText = airportMetarAnchor
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? `Airport contract anchor: use the ${anchorSourceLabel} reports. Third-party history pages are display-only when present.`
|
|||
|
|
: `机场合约锚点:以 ${anchorSourceLabel} 报文为准;第三方历史页只作为展示入口。`
|
|||
|
|
: locale === "en-US"
|
|||
|
|
? `Official anchor: use ${anchorSourceLabel} observations for this contract.`
|
|||
|
|
: `官方锚点:该合约按 ${anchorSourceLabel} 观测口径判断。`;
|
|||
|
|
const nextObservationLabel = airportMetarAnchor
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "Next METAR watch"
|
|||
|
|
: "下一次 METAR 观察"
|
|||
|
|
: locale === "en-US"
|
|||
|
|
? "Next anchor watch"
|
|||
|
|
: "下一次锚点观察";
|
|||
|
|
const gapToBaseText =
|
|||
|
|
gapToBaseBucket == null
|
|||
|
|
? "--"
|
|||
|
|
: `${gapToBaseBucket.toFixed(1)}${detail.temp_symbol || "°C"}`;
|
|||
|
|
const syncStatusItems = [
|
|||
|
|
{
|
|||
|
|
key: "base",
|
|||
|
|
state: isAnyLayerSyncing ? "syncing" : "ready",
|
|||
|
|
label: isAnyLayerSyncing
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "Refreshing base analysis"
|
|||
|
|
: "正在刷新基础分析"
|
|||
|
|
: locale === "en-US"
|
|||
|
|
? "Base analysis ready"
|
|||
|
|
: "基础分析已加载",
|
|||
|
|
note: isAnyLayerSyncing
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "Latest anchor readings and forecast curve are being rebuilt."
|
|||
|
|
: "正在重建最新锚点读数和预测曲线。"
|
|||
|
|
: locale === "en-US"
|
|||
|
|
? "Forecast curve, anchor state, and the core intraday view are available."
|
|||
|
|
: "预测曲线、锚点状态和核心日内视图已经可用。",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
key: "market",
|
|||
|
|
state: isAnyLayerSyncing ? "syncing" : "ready",
|
|||
|
|
label: isAnyLayerSyncing
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "Refreshing probability layer"
|
|||
|
|
: "正在刷新概率层"
|
|||
|
|
: locale === "en-US"
|
|||
|
|
? "Probability layer ready"
|
|||
|
|
: "概率层已加载",
|
|||
|
|
note: isAnyLayerSyncing
|
|||
|
|
? locale === "en-US"
|
|||
|
|
? "Model spread and calibrated buckets are updating."
|
|||
|
|
: "模型分歧和校准概率桶正在更新。"
|
|||
|
|
: locale === "en-US"
|
|||
|
|
? `Probability buckets are derived from the ${probabilityEngineLabel} layer.`
|
|||
|
|
: `概率桶当前由 ${probabilityEngineLabel} 层推导。`,
|
|||
|
|
},
|
|||
|
|
] satisfies FutureSyncStatusItem[];
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className="modal-overlay"
|
|||
|
|
role="dialog"
|
|||
|
|
aria-modal="true"
|
|||
|
|
aria-labelledby="future-modal-title"
|
|||
|
|
onClick={(event) => {
|
|||
|
|
if (event.target === event.currentTarget) {
|
|||
|
|
modal.closeFutureModal();
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{isProLoading ? (
|
|||
|
|
<div
|
|||
|
|
className="modal-content large"
|
|||
|
|
style={{ padding: "40px", textAlign: "center" }}
|
|||
|
|
>
|
|||
|
|
<div style={{ color: "var(--text-muted)" }}>
|
|||
|
|
{t("dashboard.loading")}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : !isPro ? (
|
|||
|
|
<ProFeaturePaywall
|
|||
|
|
feature={isToday ? "today" : "future"}
|
|||
|
|
onClose={modal.closeFutureModal}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<div className="modal-content large future-modal">
|
|||
|
|
<FutureForecastModalHeader
|
|||
|
|
cityDisplayName={detail.display_name}
|
|||
|
|
dateStr={dateStr}
|
|||
|
|
isAnyLayerSyncing={isAnyLayerSyncing}
|
|||
|
|
isPro={isPro}
|
|||
|
|
isProLoading={isProLoading}
|
|||
|
|
isToday={isToday}
|
|||
|
|
locale={locale}
|
|||
|
|
onClose={modal.closeFutureModal}
|
|||
|
|
onRefresh={() => {
|
|||
|
|
if (isToday) {
|
|||
|
|
void modal.openTodayModal(true);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
modal.openFutureModal(dateStr, true);
|
|||
|
|
}}
|
|||
|
|
t={t}
|
|||
|
|
/>
|
|||
|
|
<div
|
|||
|
|
className={clsx(
|
|||
|
|
"modal-body future-modal-body",
|
|||
|
|
isTodayBlockingRefresh && "future-modal-body-refreshing",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{isTodayBlockingRefresh && <FutureRefreshLock locale={locale} />}
|
|||
|
|
{isToday && (
|
|||
|
|
<FutureForecastTodayDecisionBrief
|
|||
|
|
anchorRuleText={anchorRuleText}
|
|||
|
|
anchorSourceLabel={anchorSourceLabel}
|
|||
|
|
baseCaseBucket={baseCaseBucket}
|
|||
|
|
confidence={intradayMeteorology.confidence}
|
|||
|
|
displayName={detail.display_name}
|
|||
|
|
downsideBucket={intradayMeteorology.downside_bucket}
|
|||
|
|
gapToBaseText={gapToBaseText}
|
|||
|
|
locale={locale}
|
|||
|
|
meteorologyHeadline={meteorologyHeadline}
|
|||
|
|
nextObservationLabel={nextObservationLabel}
|
|||
|
|
nextObservationTime={nextObservationTime}
|
|||
|
|
pathStatus={pathStatus}
|
|||
|
|
settlementStationName={settlementStationName}
|
|||
|
|
upsideBucket={intradayMeteorology.upside_bucket}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
<FutureSyncStatusStrip items={syncStatusItems} compact={isToday} />
|
|||
|
|
{isNoaaSettlement && (
|
|||
|
|
<div className="modal-callout modal-callout-info">
|
|||
|
|
{locale === "en-US"
|
|||
|
|
? `${detail.display_name} now settles against NOAA ${noaaStationCode} (${noaaStationName}). The market uses the highest rounded whole-degree Celsius reading in the Temp column after the day is finalized.`
|
|||
|
|
: `${detail.display_name} 当前按 NOAA ${noaaStationCode}(${noaaStationName})结算。市场最终采用该日 Temp 列完成质控后的最高整度摄氏值,不按小数温度结算。`}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{isToday ? (
|
|||
|
|
<div className="future-v2-layout">
|
|||
|
|
<aside className="future-v2-left">
|
|||
|
|
<FutureAnchorStatusCard
|
|||
|
|
locale={locale}
|
|||
|
|
currentTempText={currentTempText}
|
|||
|
|
weatherSummary={weatherSummary}
|
|||
|
|
obsTime={detail.current?.obs_time}
|
|||
|
|
daylightProgress={daylightProgress}
|
|||
|
|
sunrise={detail.forecast?.sunrise}
|
|||
|
|
sunset={detail.forecast?.sunset}
|
|||
|
|
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" : "上修"} ·{" "}
|
|||
|
|
{intradayMeteorology.upside_bucket || "--"}
|
|||
|
|
</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>
|
|||
|
|
) : (
|
|||
|
|
<FutureForecastForwardView
|
|||
|
|
dateStr={dateStr}
|
|||
|
|
detail={detail}
|
|||
|
|
t={t}
|
|||
|
|
view={view}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|