Files
PolyWeather/frontend/components/dashboard/PanelSections.tsx
T
2026-04-22 00:38:19 +08:00

1354 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import { startTransition, useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import {
CityDetail,
MarketScan,
MarketTopBucket,
ProbabilityBucket,
} from "@/lib/dashboard-types";
import {
getHeroMetaItems,
getModelView,
getProbabilityView,
getRiskBadgeLabel,
getTemperatureChartData,
getWeatherSummary,
} from "@/lib/dashboard-utils";
function EmptyState({ text }: { text: string }) {
return (
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>
);
}
function toPercent(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return `${(numeric * 100).toFixed(1)}%`;
}
function toPriceCents(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
const normalized = numeric > 1 ? numeric / 100 : numeric;
const cents = normalized * 100;
const rounded = Math.round(cents * 10) / 10;
const text = Number.isInteger(rounded)
? String(rounded.toFixed(0))
: String(rounded);
return `${text}c`;
}
function parseTempFromText(value: unknown) {
const text = String(value || "");
const match = text.match(/(-?\d+(?:\.\d+)?)/);
if (!match) return null;
const numeric = Number(match[1]);
return Number.isFinite(numeric) ? numeric : null;
}
function getBucketTemp(bucket: ProbabilityBucket) {
if (bucket.value != null) {
const byValue = Number(bucket.value);
if (Number.isFinite(byValue)) return byValue;
}
return parseTempFromText(bucket.label || bucket.bucket || bucket.range);
}
function getMarketYesPrice(scan?: MarketScan | null) {
if (scan?.market_price != null) {
const preferred = Number(scan.market_price);
if (Number.isFinite(preferred)) return preferred;
}
if (scan?.yes_token?.implied_probability != null) {
const implied = Number(scan.yes_token.implied_probability);
if (Number.isFinite(implied)) return implied;
}
return null;
}
function isFahrenheitSymbol(symbol?: string | null) {
return String(symbol || "").toUpperCase().includes("F");
}
function displayTempToMarketCelsius(
value: number | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (value == null || !Number.isFinite(value)) return null;
if (isFahrenheitSymbol(detail.temp_symbol)) {
return ((value - 32) * 5) / 9;
}
return value;
}
function findMarketBucketForDisplayTemp(
buckets: MarketTopBucket[],
displayTemp: number | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (displayTemp == null || !Number.isFinite(displayTemp)) return null;
let best: MarketTopBucket | null = null;
let bestDelta = Number.POSITIVE_INFINITY;
for (const bucket of buckets) {
const bucketUnit = String(bucket.unit || "").toUpperCase();
const compareTemp =
bucketUnit === "F"
? displayTemp
: displayTempToMarketCelsius(displayTemp, detail);
if (compareTemp == null) continue;
const lower = bucket.lower != null ? Number(bucket.lower) : null;
const upper = bucket.upper != null ? Number(bucket.upper) : null;
if (
lower != null &&
upper != null &&
Number.isFinite(lower) &&
Number.isFinite(upper) &&
compareTemp >= lower - 0.01 &&
compareTemp <= upper + 0.01
) {
return bucket;
}
const rawTemp = bucket.temp ?? bucket.value ?? null;
if (rawTemp == null) continue;
const candidateTemp = Number(rawTemp);
if (!Number.isFinite(candidateTemp)) continue;
const delta = Math.abs(candidateTemp - compareTemp);
if (delta < bestDelta) {
best = bucket;
bestDelta = delta;
}
}
const tolerance = isFahrenheitSymbol(detail.temp_symbol) ? 0.56 : 0.26;
return best && bestDelta <= tolerance ? best : null;
}
type ModelMetadata = NonNullable<
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
>["model_metadata"];
function getModelGroupMeta(
name: string,
metadata: ModelMetadata,
locale: string,
) {
const meta = metadata?.[name] || {};
const tier = String(meta.tier || "").toLowerCase();
const upperName = String(name || "").toUpperCase();
if (tier.includes("aifs") || upperName.includes("AIFS")) {
return {
key: "aifs",
label: locale === "en-US" ? "AIFS model" : "AIFS 模型",
order: 1,
tone: "blue",
};
}
if (
tier.includes("europe") ||
upperName.includes("ICON-EU") ||
upperName.includes("ICON-D2")
) {
return {
key: "europe",
label: locale === "en-US" ? "Europe high-resolution" : "欧洲高分辨率",
order: 2,
tone: "cyan",
};
}
if (
tier.includes("north_america") ||
upperName === "RDPS" ||
upperName === "HRDPS"
) {
return {
key: "north-america",
label: locale === "en-US" ? "North America high-resolution" : "北美高分辨率",
order: 3,
tone: "amber",
};
}
return {
key: "global",
label: locale === "en-US" ? "Global baseline" : "全球基准",
order: 0,
tone: "neutral",
};
}
function formatModelMetaLine(
name: string,
metadata: ModelMetadata,
locale: string,
) {
const meta = metadata?.[name] || {};
const provider = String(meta.provider || "").trim();
const model = String(meta.model || "").trim();
const horizon = String(meta.horizon || "").trim();
const resolution = Number(meta.resolution_km);
const parts = [
provider,
model && model !== name ? model : "",
Number.isFinite(resolution)
? `${resolution}${locale === "en-US" ? " km" : " 公里"}`
: "",
horizon,
].filter(Boolean);
return parts.join(" · ");
}
function normalizeModelNameForVote(name: string) {
return String(name || "")
.trim()
.toLowerCase()
.replace(/[\s_/-]/g, "");
}
function getModelVoteFamily(name: string) {
const normalized = normalizeModelNameForVote(name);
if (["icon", "iconeu", "icond2"].includes(normalized)) return "dwd_icon";
if (["gem", "gdps", "rdps", "hrdps"].includes(normalized)) return "eccc_gem";
if (["ecmwfaifs", "aifs"].includes(normalized)) return "ecmwf_aifs";
if (normalized === "ecmwf") return "ecmwf_ifs";
return normalized || name;
}
function getModelVotePriority(name: string) {
const normalized = normalizeModelNameForVote(name);
return (
{
icond2: 40,
iconeu: 30,
icon: 20,
hrdps: 40,
rdps: 35,
gdps: 30,
gem: 20,
ecmwfaifs: 30,
ecmwf: 30,
gfs: 30,
jma: 30,
mgm: 45,
nws: 45,
openmeteo: 15,
}[normalized] || 10
);
}
function getRoundedModelVoteDistribution(
detail: CityDetail,
targetDate?: string | null,
) {
const view = getModelView(detail, targetDate);
const representatives = new Map<
string,
{ name: string; priority: number; value: number }
>();
Object.entries(view.models || {}).forEach(([name, rawValue]) => {
const normalized = normalizeModelNameForVote(name);
if (normalized === "lgbm" || normalized.includes("meteoblue")) return;
const value = Number(rawValue);
if (!Number.isFinite(value)) return;
const family = getModelVoteFamily(name);
const priority = getModelVotePriority(name);
const current = representatives.get(family);
if (!current || priority > current.priority) {
representatives.set(family, { name, priority, value });
}
});
const bucketMap = new Map<number, { count: number; models: string[] }>();
representatives.forEach(({ name, value }) => {
const rounded = Math.round(value);
const row = bucketMap.get(rounded) || { count: 0, models: [] };
row.count += 1;
row.models.push(name);
bucketMap.set(rounded, row);
});
const total = representatives.size;
const rows = Array.from(bucketMap.entries())
.map(([value, row]) => ({
count: row.count,
models: row.models,
percent: total > 0 ? row.count / total : 0,
value,
}))
.sort((a, b) => b.count - a.count || b.value - a.value);
return {
rows,
total,
};
}
function normalizeMarketProbability(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));
}
function normalizeSignedProbability(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
if (Math.abs(numeric) > 1) return numeric / 100;
return numeric;
}
function formatSignedPercent(value?: number | null, digits = 1) {
const normalized = normalizeSignedProbability(value);
if (normalized == null) return "--";
const percent = normalized * 100;
const sign = percent > 0 ? "+" : "";
return `${sign}${percent.toFixed(digits)}%`;
}
function getMarketTopBuckets(scan?: MarketScan | null) {
const buckets = Array.isArray(scan?.top_buckets) ? scan.top_buckets : [];
if (!buckets.length) return [];
return buckets
.map((item) => ({
...item,
probability: normalizeMarketProbability(item.probability),
}))
.filter(
(item): item is MarketTopBucket & { probability: number } =>
item.probability != null,
);
}
function getMarketAllBuckets(scan?: MarketScan | null) {
const buckets = Array.isArray(scan?.all_buckets)
? scan.all_buckets
: Array.isArray(scan?.top_buckets)
? scan.top_buckets
: [];
if (!buckets.length) return [];
return buckets
.map((item) => ({
...item,
probability: normalizeMarketProbability(item.probability),
}))
.filter(
(item): item is MarketTopBucket & { probability: number } =>
item.probability != null,
);
}
function getMarketTopBucketKey(bucket: MarketTopBucket) {
if (bucket?.value != null) {
const valueNum = Number(bucket.value);
if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`;
}
if (bucket?.temp != null) {
const tempNum = Number(bucket.temp);
if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`;
}
const parsed = parseTempFromText(bucket?.label);
if (parsed != null) return `l:${parsed.toFixed(2)}`;
return `s:${String(bucket?.slug || bucket?.question || bucket?.label || "")}`;
}
function hasLgbmModel(detail: CityDetail, targetDate?: string | null) {
const view = getModelView(detail, targetDate);
return Object.keys(view.models || {}).some((name) =>
normalizeModelNameForVote(name).includes("lgbm"),
);
}
function formatProbabilityEngineLabel(
detail: CityDetail,
targetDate: string | null | undefined,
locale: string,
) {
const view = getProbabilityView(detail, targetDate);
if (hasLgbmModel(detail, targetDate)) {
return locale === "en-US"
? "LGBM-calibrated probability"
: "LGBM 校准概率";
}
const engine = String(view.engine || "").trim().toLowerCase();
const calibrationMode = String(view.calibrationMode || "")
.trim()
.toLowerCase();
if (engine === "emos" || calibrationMode.includes("emos")) {
return locale === "en-US" ? "EMOS-calibrated probability" : "EMOS 校准概率";
}
return locale === "en-US" ? "Model probability" : "模型概率";
}
export function HeroSummary() {
const { data } = useCityData();
const { locale } = useI18n();
if (!data) return null;
const { weatherIcon, weatherText } = getWeatherSummary(data, locale);
const metaItems = getHeroMetaItems(data, locale);
const current = data.current || {};
const settlementSourceCode = String(current.settlement_source || "metar")
.trim()
.toLowerCase();
const settlementIcao = String(
current.station_code || data.risk?.icao || "",
)
.trim()
.toUpperCase();
const settlementSource =
settlementSourceCode === "wunderground"
? settlementIcao
? `${settlementIcao} METAR`
: "METAR"
: String(current.settlement_source_label || current.settlement_source || "METAR")
.trim()
.toUpperCase();
const isMax =
current.max_so_far != null &&
current.temp != null &&
current.max_so_far <= current.temp;
const currentObsText =
current.temp != null
? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}`
: data.metar_status?.stale_for_today
? locale === "en-US"
? "No same-day METAR"
: "今日暂无 METAR"
: "--";
return (
<section className="hero-section">
<div className="hero-weather">
<span>
{weatherIcon} {weatherText}
</span>
</div>
<div className="hero-temp">
<span className="hero-value">
{current.temp != null ? current.temp.toFixed(1) : "--"}
</span>
<span className="hero-unit">{data.temp_symbol || "°C"}</span>
</div>
<div className="hero-max-time">
{isMax && current.max_temp_time
? locale === "en-US"
? `Today's peak temperature appeared at local time ${current.max_temp_time}`
: `该城市今日最高温出现在当地时间 ${current.max_temp_time}`
: ""}
</div>
<div className="hero-details">
<div className="hero-item">
<span className="label">
{locale === "en-US" ? "Current Obs" : "当前实测"}
</span>
<span className="value">
{currentObsText}
</span>
</div>
<div className="hero-item">
<span className="label">
{locale === "en-US"
? `${settlementSource} Anchor`
: `${settlementSource} 锚点`}
</span>
<span className="value highlight">
{current.wu_settlement != null
? `${current.wu_settlement}${data.temp_symbol}`
: "--"}
</span>
</div>
<div className="hero-item">
<span className="label">
{locale === "en-US" ? "DEB Forecast" : "DEB 预测"}
</span>
<span className="value">
{data.deb?.prediction != null
? `${data.deb.prediction}${data.temp_symbol}`
: "--"}
</span>
</div>
</div>
<div className="hero-sub">
{metaItems.map((item) => (
<span key={item}>{item}</span>
))}
</div>
</section>
);
}
export function TemperatureChart() {
const { data } = useCityData();
const { locale, t } = useI18n();
const chartData = useMemo(
() => (data ? getTemperatureChartData(data, locale) : null),
[data, locale],
);
const canvasRef = useChart(() => {
if (!data || !chartData) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [];
if (chartData.datasets.hasMgmHourly) {
datasets.push({
backgroundColor: "rgba(234, 179, 8, 0.05)",
borderColor: "rgba(234, 179, 8, 0.8)",
borderWidth: 2,
data: chartData.datasets.mgmHourlyPoints,
fill: false,
label: locale === "en-US" ? "MGM Forecast" : "MGM 预报",
pointHoverRadius: 6,
pointRadius: 3,
spanGaps: true,
tension: 0.3,
});
} else {
datasets.push({
backgroundColor: "rgba(52, 211, 153, 0.05)",
borderColor: "rgba(52, 211, 153, 0.6)",
borderWidth: 1.5,
data: chartData.datasets.debPast,
fill: true,
label: locale === "en-US" ? "DEB Forecast" : "DEB 预报",
pointHoverRadius: 3,
pointRadius: 0,
tension: 0.3,
});
datasets.push({
borderColor: "rgba(52, 211, 153, 0.35)",
borderDash: [5, 3],
borderWidth: 1.5,
data: chartData.datasets.debFuture,
fill: false,
label: locale === "en-US" ? "DEB Forecast" : "DEB 预报",
pointRadius: 0,
tension: 0.3,
});
}
datasets.push({
backgroundColor: "#22d3ee",
borderColor: "#22d3ee",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label:
chartData.observationLabel ||
(locale === "en-US" ? "METAR Observation" : "METAR 实况"),
order: 0,
pointHoverRadius: 7,
pointRadius: 5,
});
if (chartData.datasets.mgmPoints.some((value) => value != null)) {
datasets.push({
backgroundColor: "#facc15",
borderColor: "#facc15",
borderWidth: 0,
data: chartData.datasets.mgmPoints,
fill: false,
label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
order: -1,
pointHoverRadius: 9,
pointRadius: 7,
showLine: false,
});
}
if (
!chartData.datasets.hasMgmHourly &&
Math.abs(chartData.datasets.offset) > 0.3
) {
datasets.push({
borderColor: "rgba(99, 102, 241, 0.2)",
borderDash: [2, 4],
borderWidth: 1,
data: chartData.datasets.temps,
fill: false,
label: locale === "en-US" ? "OM Raw" : "OM 原始",
pointRadius: 0,
tension: 0.3,
});
}
return {
data: {
datasets,
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.9)",
borderColor: "rgba(52, 211, 153, 0.3)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 3 === 0
? chartData.times[index]
: "",
color: "#64748b",
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) => `${value}${data.temp_symbol || "°C"}`,
color: "#64748b",
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [data, chartData, locale]);
return (
<section className="chart-section">
<h3>{t("section.todayTempTrend")}</h3>
<div className="chart-wrapper">
<canvas ref={canvasRef} />
</div>
<div className="chart-legend">
{chartData?.legendText || t("section.chartEmpty")}
</div>
</section>
);
}
export function ProbabilityDistribution({
detail,
hideTitle = false,
targetDate,
marketScan,
}: {
detail: CityDetail;
hideTitle?: boolean;
targetDate?: string | null;
marketScan?: MarketScan | null;
}) {
const { locale, t } = useI18n();
const view = getProbabilityView(detail, targetDate);
const marketYesPrice = getMarketYesPrice(marketScan);
const marketYesText = toPercent(marketYesPrice);
const isToday = !targetDate || targetDate === detail.local_date;
const probabilityEngineLabel = formatProbabilityEngineLabel(
detail,
targetDate,
locale,
);
const hasLgbmProbability = hasLgbmModel(detail, targetDate);
const modelVoteView = useMemo(
() => getRoundedModelVoteDistribution(detail, targetDate),
[detail, targetDate],
);
const modelVoteHint = modelVoteView.rows
.slice(0, 2)
.map(
(row) =>
`${row.value}${detail.temp_symbol} ${row.count}/${modelVoteView.total}`,
)
.join(" · ");
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
const marketAllBuckets = isToday ? getMarketAllBuckets(marketScan) : [];
const sortedMarketTopBuckets = useMemo(() => {
const sorted = [...marketTopBuckets].sort(
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
);
const deduped: Array<MarketTopBucket & { probability: number }> = [];
const seenKeys = new Set<string>();
for (const row of sorted) {
const key = getMarketTopBucketKey(row);
if (seenKeys.has(key)) continue;
seenKeys.add(key);
deduped.push(row);
if (deduped.length >= 4) break;
}
return deduped;
}, [marketTopBuckets]);
const useMarketTopBuckets =
marketScan?.available && sortedMarketTopBuckets.length >= 2;
const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
const topProbability = [...(view.probabilities || [])].sort(
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
)[0];
const topProbabilityText = toPercent(topProbability?.probability);
const topProbabilityLabel = topProbability
? topProbability.label || `${topProbability.value}${detail.temp_symbol}`
: null;
const topProbabilityTemp = topProbability ? getBucketTemp(topProbability) : null;
const linkedMarketBucket = useMemo(() => {
if (topProbabilityTemp == null) return null;
return findMarketBucketForDisplayTemp(
marketAllBuckets,
topProbabilityTemp,
detail,
);
}, [detail, marketAllBuckets, topProbabilityTemp]);
const priceAnalysis = marketScan?.price_analysis;
const yesPriceView = priceAnalysis?.yes;
const noPriceView = priceAnalysis?.no;
const linkedMarketAsk =
linkedMarketBucket?.yes_buy ??
linkedMarketBucket?.market_price ??
yesPriceView?.ask ??
null;
const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null;
const linkedMarketProbability =
topProbability?.probability != null ? Number(topProbability.probability) : null;
const linkedMarketEdge =
linkedMarketProbability != null && linkedMarketAsk != null
? linkedMarketProbability - Number(linkedMarketAsk)
: null;
const linkedNoEdge =
linkedMarketProbability != null && linkedNoAsk != null
? 1 - linkedMarketProbability - Number(linkedNoAsk)
: null;
const linkedBestSide =
linkedMarketBucket && linkedNoEdge != null && linkedMarketEdge != null
? linkedNoEdge > linkedMarketEdge
? "no"
: "yes"
: null;
const linkedBestAsk = linkedBestSide === "no" ? linkedNoAsk : linkedMarketAsk;
const linkedBestEdge =
linkedBestSide === "no" ? linkedNoEdge : linkedMarketEdge;
const preferredPriceView =
linkedMarketBucket
? {
ask: linkedBestAsk,
edge: linkedBestEdge,
}
: priceAnalysis?.best_side === "no"
? noPriceView
: yesPriceView;
const preferredSideLabel =
linkedMarketBucket
? linkedBestSide === "no"
? "NO"
: "YES"
: priceAnalysis?.best_side === "no"
? locale === "en-US"
? "NO"
: "NO"
: locale === "en-US"
? "YES"
: "YES";
const yesDisplayPrice = linkedMarketBucket ? linkedMarketAsk : yesPriceView?.ask;
const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask;
const yesDisplayEdge = linkedMarketBucket
? linkedMarketEdge
: yesPriceView?.edge;
const noDisplayEdge = linkedMarketBucket ? linkedNoEdge : noPriceView?.edge;
const hasPriceAnalysis =
isToday &&
(Boolean(priceAnalysis?.available) ||
Boolean(marketScan) ||
Boolean(topProbability));
const lockEdge = normalizeSignedProbability(priceAnalysis?.lock?.edge);
const lockAvailable = Boolean(priceAnalysis?.lock?.available && lockEdge != null);
const quoteSource =
linkedMarketBucket?.quote_source ||
marketScan?.yes_token?.quote_source ||
marketScan?.no_token?.quote_source ||
null;
const quoteAgeMs =
linkedMarketBucket?.quote_age_ms ??
marketScan?.yes_token?.quote_age_ms ??
marketScan?.no_token?.quote_age_ms;
const quoteSourceLabel =
quoteSource === "polymarket_ws"
? locale === "en-US"
? `WS live${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}s` : ""}`
: `WS 实时${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}秒` : ""}`
: locale === "en-US"
? "CLOB fallback"
: "CLOB 兜底";
const actionableEdge = normalizeSignedProbability(preferredPriceView?.edge);
const actionText =
!marketScan
? locale === "en-US"
? "Waiting"
: "等待"
: !marketScan.available
? locale === "en-US"
? "No market"
: "无盘口"
: actionableEdge == null
? locale === "en-US"
? "No quote"
: "无报价"
: actionableEdge >= 0.02
? locale === "en-US"
? `Watch ${preferredSideLabel}`
: `可关注 ${preferredSideLabel}`
: actionableEdge > 0
? locale === "en-US"
? `Small ${preferredSideLabel}`
: `${preferredSideLabel} 优势较小`
: locale === "en-US"
? "No clear edge"
: "暂无优势";
const actionNote =
actionableEdge != null && actionableEdge >= 0.02
? locale === "en-US"
? `${formatSignedPercent(actionableEdge)} vs ask`
: `相对买价 ${formatSignedPercent(actionableEdge)}`
: locale === "en-US"
? `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`
: `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`;
return (
<section className="prob-section">
{!hideTitle && <h3>{t("section.probability")}</h3>}
<div className="prob-bars">
<div className="prob-calibration-head">
<div>
<span className="prob-source-chip">{probabilityEngineLabel}</span>
<strong>
{topProbability && topProbabilityText
? locale === "en-US"
? `${topProbabilityLabel} leads at ${topProbabilityText}`
: `${topProbabilityLabel} 当前最高,${topProbabilityText}`
: locale === "en-US"
? "Awaiting calibrated buckets"
: "等待校准概率桶"}
</strong>
</div>
<p>
{hasLgbmProbability
? locale === "en-US"
? "LGBM is the learned intraday adjustment; model consensus below remains an explanation layer."
: "LGBM 作为日内学习校准项;下方模型共识只保留为解释层。"
: locale === "en-US"
? "Using the calibrated model distribution; model consensus below is for explanation only."
: "使用校准后的模型分布;下方模型共识仅用于解释。"}
</p>
</div>
{marketScan?.available && (topMarketBucketText || marketYesText) && (
<div
style={{
color: "var(--text-secondary)",
fontSize: "11px",
marginBottom: "6px",
}}
>
{useMarketTopBuckets
? locale === "en-US"
? `Market reference only: top traded bucket ${topMarketBucketText}`
: `市场仅作参考:最高交易温度桶 ${topMarketBucketText}`
: locale === "en-US"
? `Market reference only: this bucket ${marketYesText}`
: `市场仅作参考:该温度桶 ${marketYesText}`}
</div>
)}
{hasPriceAnalysis && (
<div className="prob-price-card">
<div className="prob-price-head">
<span>
{locale === "en-US" ? "Probability x Price" : "概率 x 价格联动"}
</span>
<strong>
{!marketScan
? locale === "en-US"
? "Waiting for market layer"
: "等待市场层"
: !marketScan.available
? locale === "en-US"
? "No matched active market"
: "未匹配到活跃盘口"
: linkedMarketBucket
? locale === "en-US"
? `${actionText}: ${topProbabilityLabel || "top bucket"}`
: `${actionText}${topProbabilityLabel || "最高概率桶"}`
: preferredPriceView?.edge != null
? locale === "en-US"
? `${actionText} · edge ${formatSignedPercent(preferredPriceView.edge)}`
: `${actionText} · 优势 ${formatSignedPercent(preferredPriceView.edge)}`
: locale === "en-US"
? "Waiting for executable quote"
: "等待可执行报价"}
</strong>
</div>
<div className="prob-price-grid">
<div>
<span>{locale === "en-US" ? "Model bucket" : "概率主桶"}</span>
<strong>{topProbabilityLabel || "--"}</strong>
<em>{topProbabilityText || "--"}</em>
</div>
<div>
<span>{locale === "en-US" ? "Candidate" : "可关注"}</span>
<strong>{actionText}</strong>
<em>{actionNote}</em>
</div>
<div>
<span>{locale === "en-US" ? "YES price" : "YES 价格"}</span>
<strong>{toPriceCents(yesDisplayPrice) || "--"}</strong>
<em>
{locale === "en-US"
? `edge ${formatSignedPercent(yesDisplayEdge)}`
: `优势 ${formatSignedPercent(yesDisplayEdge)}`}
</em>
</div>
<div>
<span>{locale === "en-US" ? "NO price" : "NO 价格"}</span>
<strong>{toPriceCents(noDisplayPrice) || "--"}</strong>
<em>
{locale === "en-US"
? `edge ${formatSignedPercent(noDisplayEdge)}`
: `优势 ${formatSignedPercent(noDisplayEdge)}`}
</em>
</div>
</div>
<p>
{locale === "en-US"
? `Read-only comparison between model probability and executable ask; it does not place orders. Source: ${quoteSourceLabel}${lockAvailable ? ` · lock ${formatSignedPercent(lockEdge)}` : ""}.`
: `只比较模型概率与可执行买价;系统不会下单。来源:${quoteSourceLabel}${lockAvailable ? ` · 锁价 ${formatSignedPercent(lockEdge)}` : ""}。`}
</p>
</div>
)}
{modelVoteHint && (
<div className="prob-model-hint">
<span>
{locale === "en-US" ? "Model consensus" : "模型共识参考"}
</span>
<strong>{modelVoteHint}</strong>
<em>
{locale === "en-US"
? "explains clustering, not calibrated probability"
: "解释模型聚集,不等同于校准概率"}
</em>
</div>
)}
{view.probabilities.length === 0 ? (
<EmptyState text={t("section.noProb")} />
) : (
view.probabilities.slice(0, 6).map((bucket, index) => {
const probability = Math.round(
Number(bucket.probability || 0) * 100,
);
const bucketTemp = getBucketTemp(bucket);
const rowMarketBucket = findMarketBucketForDisplayTemp(
marketAllBuckets,
bucketTemp,
detail,
);
const rowMarketPrice =
rowMarketBucket?.market_price ?? rowMarketBucket?.yes_buy ?? null;
const yesPriceText = toPriceCents(rowMarketPrice);
const marketTagFinal = rowMarketBucket
? locale === "en-US"
? `Market ref: ${yesPriceText || "--"}`
: `市场参考: ${yesPriceText || "--"}`
: null;
let bucketLabel =
bucket.label || `${bucket.value}${detail.temp_symbol}`;
if (bucketLabel) {
let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
const symbol = detail.temp_symbol || "°C";
str = str.replace(/°?C($|\+|-)/g, "°C$1");
str = str.replace(/°?F($|\+|-)/g, "°F$1");
if (!/[°℃][CF]/.test(str) && /[0-9]/.test(str)) {
str += symbol;
}
bucketLabel = str;
}
return (
<div
key={`${bucket.label || bucket.value || index}`}
className="prob-row"
>
<div className="prob-label">{bucketLabel}</div>
<div className="prob-bar-track">
<div
className={clsx("prob-bar-fill", `rank-${index}`)}
style={{ width: `${Math.max(probability, 8)}%` }}
>
{probability}%
</div>
</div>
{marketTagFinal && (
<div
className={clsx(
"prob-market-inline",
rowMarketBucket ? "yes" : "no",
)}
>
{marketTagFinal}
</div>
)}
</div>
);
})
)}
</div>
</section>
);
}
export function ModelForecast({
detail,
hideTitle = false,
targetDate,
}: {
detail: CityDetail;
hideTitle?: boolean;
targetDate?: string | null;
}) {
const { locale, t } = useI18n();
const view = getModelView(detail, targetDate);
const modelsMap = { ...view.models };
const modelMetadata =
detail.source_forecasts?.open_meteo_multi_model?.model_metadata || {};
const modelEntries = Object.entries(modelsMap).filter(
([, value]) =>
value !== null && value !== undefined && Number.isFinite(Number(value)),
);
const hasSingleModelOnly = modelEntries.length === 1;
// 如果没有任何数值,给出提示
if (modelEntries.length === 0) {
return (
<section className="models-section">
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
<EmptyState text={t("section.noModels")} />
</div>
</section>
);
}
const numericValues = modelEntries.map(([, value]) => Number(value));
const comparisonValues =
view.deb != null ? [...numericValues, Number(view.deb)] : numericValues;
const minValue = comparisonValues.length
? Math.min(...comparisonValues) - 1
: 0;
const maxValue = comparisonValues.length
? Math.max(...comparisonValues) + 1
: 1;
const range = Math.max(maxValue - minValue, 1);
const sortedEntries = modelEntries.sort(
(a, b) => Number(b[1] || 0) - Number(a[1] || 0),
);
const groupedEntries = sortedEntries.reduce(
(acc, [name, value]) => {
const group = getModelGroupMeta(name, modelMetadata, locale);
const existing = acc.find((item) => item.key === group.key);
const entry = {
metaLine: formatModelMetaLine(name, modelMetadata, locale),
name,
value: Number(value),
};
if (existing) {
existing.entries.push(entry);
} else {
acc.push({ ...group, entries: [entry] });
}
return acc;
},
[] as Array<{
entries: Array<{ metaLine: string; name: string; value: number }>;
key: string;
label: string;
order: number;
tone: string;
}>,
).sort((a, b) => a.order - b.order);
const spread =
numericValues.length >= 2
? Math.max(...numericValues) - Math.min(...numericValues)
: null;
const metadataSource =
detail.source_forecasts?.open_meteo_multi_model?.provider === "open-meteo"
? "Open-Meteo"
: null;
return (
<section className="models-section">
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
<div className="model-stack-summary">
<span>
{locale === "en-US" ? "Available models" : "可用模型"} ·{" "}
<strong>{modelEntries.length}</strong>
</span>
<span>
{locale === "en-US" ? "Spread" : "分歧"} ·{" "}
<strong>
{spread != null
? `${spread.toFixed(1)}${detail.temp_symbol}`
: "--"}
</strong>
</span>
{metadataSource && (
<span>
{locale === "en-US" ? "API" : "接口"} ·{" "}
<strong>{metadataSource}</strong>
</span>
)}
</div>
{hasSingleModelOnly && (
<div
style={{
color: "var(--text-secondary)",
fontSize: "11px",
marginBottom: "8px",
}}
>
{locale === "en-US"
? "Single-model fallback: waiting for the rest of the model cluster."
: "当前处于单模型回退,其他模型结果还没回传。"}
</div>
)}
{groupedEntries.map((group) => (
<div
key={group.key}
className={clsx("model-group", `model-group-${group.tone}`)}
>
<div className="model-group-heading">
<span>{group.label}</span>
<em>{group.entries.length}</em>
</div>
{group.entries.map(({ metaLine, name, value }) => {
const width = ((value - minValue) / range) * 100;
const debLine =
view.deb != null
? ((Number(view.deb) - minValue) / range) * 100
: null;
return (
<div key={name} className="model-row model-row-rich">
<div className="model-name" title={metaLine || name}>
<strong>{name}</strong>
{metaLine && <span>{metaLine}</span>}
</div>
<div className="model-bar-track">
<div
className="model-bar-fill"
style={{ width: `${width}%` }}
/>
<span className="model-bar-value">
{value}
{detail.temp_symbol}
</span>
{debLine != null && (
<div
className="model-deb-line"
style={{ left: `${debLine}%` }}
/>
)}
</div>
</div>
);
})}
</div>
))}
{view.deb != null && (
<div
className="model-row"
style={{
borderTop: "1px solid rgba(255,255,255,0.06)",
marginTop: "6px",
paddingTop: "6px",
}}
>
<div
className="model-name"
style={{ color: "var(--accent-cyan)", fontWeight: 700 }}
>
DEB
</div>
<div className="model-bar-track">
<div
className="model-bar-fill deb"
style={{
width: `${((Number(view.deb) - minValue) / range) * 100}%`,
}}
/>
<span className="model-bar-value deb">
{Number(view.deb)}
{detail.temp_symbol}
</span>
</div>
</div>
)}
</div>
</section>
);
}
export function ForecastTable() {
const store = useDashboardStore();
const { data } = useCityData();
const { locale, t } = useI18n();
if (!data) return null;
const daily = data.forecast?.daily || [];
const isSparseDaily = daily.length <= 1;
const isForecastCompleting =
store.loadingState.cityDetail &&
(data.detail_depth !== "full" || isSparseDaily);
const resolveForecastTemp = (date: string, fallback: number | null | undefined) => {
const debPrediction = data.multi_model_daily?.[date]?.deb?.prediction;
return debPrediction ?? fallback ?? null;
};
return (
<section className="forecast-section">
<h3>{t("forecast.title")}</h3>
{isSparseDaily && (
<div className="forecast-inline-note">
{isForecastCompleting
? locale === "en-US"
? "Multi-day forecast is syncing. Only the current-day card has arrived."
: "多日预报同步中,当前只到达当日卡片。"
: locale === "en-US"
? "Only the current-day forecast is available right now."
: "当前只收到当日预报,其他日期结果暂未回传。"}
</div>
)}
<div className="forecast-table">
{daily.length === 0 ? (
<EmptyState text={t("forecast.empty")} />
) : (
daily.map((day, index) => {
const isToday = day.date === data.local_date || index === 0;
const isSelected =
(isToday &&
store.forecastModalMode === "today" &&
Boolean(store.futureModalDate)) ||
(store.forecastModalMode !== "today" &&
store.futureModalDate === day.date) ||
store.selectedForecastDate === day.date;
return (
<button
key={day.date}
type="button"
className={clsx(
"forecast-day",
isToday && "today",
isSelected && "selected",
)}
onClick={() => {
startTransition(() => {
if (isToday) {
store.openTodayModal();
return;
}
store.openFutureModal(day.date);
});
}}
>
<div className="f-date">
{isToday
? t("forecast.today")
: day.date.substring(5).replace("-", "/")}
</div>
<div className="f-temp">
{resolveForecastTemp(day.date, day.max_temp)}
{data.temp_symbol}
</div>
</button>
);
}).concat(
isForecastCompleting
? Array.from({ length: Math.max(0, 5 - daily.length) }).map((_, index) => (
<button
key={`forecast-sync-${index}`}
type="button"
className="forecast-day forecast-day-sync"
disabled
>
<div className="f-date">
{locale === "en-US" ? "Syncing" : "同步中"}
</div>
<div className="f-temp">--</div>
</button>
))
: [],
)
)}
</div>
</section>
);
}
export function RiskInfo() {
const { data } = useCityData();
const { t } = useI18n();
if (!data) return null;
const risk = data.risk || {};
return (
<section className="risk-section">
<h3>{t("section.risk")}</h3>
<div className="risk-info">
{!risk.airport ? (
<span style={{ color: "var(--text-muted)" }}>
{t("section.noRiskProfile")}
</span>
) : (
<>
<div className="risk-row">
<span className="risk-label">{t("section.airport")}</span>
<span>
{risk.airport} ({risk.icao})
</span>
</div>
<div className="risk-row">
<span className="risk-label">{t("section.distance")}</span>
<span>{risk.distance_km}km</span>
</div>
{risk.warning && (
<div className="risk-row">
<span className="risk-label">{t("section.note")}</span>
<span>{risk.warning}</span>
</div>
)}
</>
)}
</div>
</section>
);
}