Files
PolyWeather/frontend/components/dashboard/FutureForecastModalContent.tsx
T

1000 lines
36 KiB
TypeScript
Raw Normal View History

"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 { FutureForecastTodayDecisionBrief } from "./FutureForecastTodayDecisionBrief";
import { FutureForecastTodayLayout } from "./FutureForecastTodayLayout";
import { FutureForecastModalHeader } from "./FutureForecastModalHeader";
import {
FutureRefreshLock,
FutureSyncStatusStrip,
type FutureSyncStatusItem,
} from "./FutureForecastModalStatus";
import { 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 ? (
<FutureForecastTodayLayout
locale={locale}
isToday={isToday}
dateStr={dateStr}
detail={detail}
currentTempText={currentTempText}
weatherSummary={weatherSummary}
daylightProgress={daylightProgress}
topObservedTemp={topObservedTemp}
gapToBaseBucket={gapToBaseBucket}
pathStatus={pathStatus}
showDeferredTodaySections={showDeferredTodaySections}
paceView={paceView}
paceSignalItems={paceSignalItems}
baseCaseBucket={baseCaseBucket}
upsideBucket={intradayMeteorology.upside_bucket}
nextObservationTime={nextObservationTime}
airportMetarAnchor={airportMetarAnchor}
confirmationRules={confirmationRules}
invalidationRules={invalidationRules}
meteorologySignals={meteorologySignals}
modelSummary={modelSummary}
probabilityTitle={probabilityTitle}
probabilitySummary={probabilitySummary}
activeMarketScan={activeMarketScan}
/>
) : (
<FutureForecastForwardView
dateStr={dateStr}
detail={detail}
t={t}
view={view}
/>
)}
</div>
</div>
)}
</div>
);
}