feat: add PanelSections component for dashboard weather data visualization and market bucket analysis

This commit is contained in:
2569718930@qq.com
2026-04-22 01:09:22 +08:00
parent 8e949821f2
commit 988f906f23
+120 -16
View File
@@ -227,6 +227,56 @@ function getAggregatedModelProbabilityForMarketBucket(
return matched > 0 ? Math.max(0, Math.min(1, total)) : null; return matched > 0 ? Math.max(0, Math.min(1, total)) : null;
} }
type ProbabilityDisplayRow = {
key: string;
label: string;
probability: number;
marketBucket?: MarketTopBucket | null;
};
function formatMarketBucketDisplayLabel(
bucket: MarketTopBucket,
detail: Pick<CityDetail, "temp_symbol">,
) {
const label = String(bucket.label || "").trim();
if (label) {
const unit = getMarketBucketUnit(bucket);
let normalized = label.toUpperCase().replace(/\s+/g, "");
if (unit === "F" || isFahrenheitSymbol(detail.temp_symbol)) {
normalized = normalized
.replace(/ORHIGHER/g, "+")
.replace(/ORLOWER/g, "-")
.replace(/℃/g, "°F")
.replace(/°C/g, "°F")
.replace(/(?<=\d)F/g, "°F");
} else {
normalized = normalized
.replace(/ORHIGHER/g, "+")
.replace(/ORLOWER/g, "-")
.replace(/℃/g, "°C")
.replace(/°F/g, "°C")
.replace(/(?<=\d)C/g, "°C");
}
return normalized.replace(/\+/g, "+");
}
const unit =
getMarketBucketUnit(bucket) === "F" || isFahrenheitSymbol(detail.temp_symbol)
? "°F"
: "°C";
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)) {
return `${lower}-${upper}${unit}`;
}
const value = bucket.value ?? bucket.temp ?? lower;
const numeric = value != null ? Number(value) : null;
if (numeric != null && Number.isFinite(numeric)) {
return isMarketBucketAbove(bucket) ? `${numeric}${unit}+` : `${numeric}${unit}`;
}
return "--";
}
type ModelMetadata = NonNullable< type ModelMetadata = NonNullable<
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"] NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
>["model_metadata"]; >["model_metadata"];
@@ -808,14 +858,72 @@ export function ProbabilityDistribution({
? formatBucketDisplayLabel(topProbability, detail) ? formatBucketDisplayLabel(topProbability, detail)
: null; : null;
const topProbabilityTemp = topProbability ? getBucketTemp(topProbability) : null; const topProbabilityTemp = topProbability ? getBucketTemp(topProbability) : null;
const marketContractRows = useMemo<ProbabilityDisplayRow[]>(() => {
if (!isToday || !marketScan?.available || marketAllBuckets.length === 0) {
return [];
}
const rows: ProbabilityDisplayRow[] = [];
const seenKeys = new Set<string>();
for (const marketBucket of marketAllBuckets) {
const probability = getAggregatedModelProbabilityForMarketBucket(
view.probabilities || [],
marketBucket,
detail,
);
if (probability == null) continue;
const key =
marketBucket.slug ||
marketBucket.label ||
`${marketBucket.lower ?? marketBucket.value ?? marketBucket.temp}-${marketBucket.upper ?? ""}`;
if (seenKeys.has(key)) continue;
seenKeys.add(key);
rows.push({
key,
label: formatMarketBucketDisplayLabel(marketBucket, detail),
probability,
marketBucket,
});
}
return rows;
}, [detail, isToday, marketAllBuckets, marketScan?.available, view.probabilities]);
const modelProbabilityRows = useMemo<ProbabilityDisplayRow[]>(
() =>
(view.probabilities || []).slice(0, 6).map((bucket, index) => {
const bucketTemp = getBucketTemp(bucket);
return {
key: `${bucket.label || bucket.value || index}`,
label: formatBucketDisplayLabel(bucket, detail),
probability: Number(bucket.probability || 0),
marketBucket: findMarketBucketForDisplayTemp(
marketAllBuckets,
bucketTemp,
detail,
),
};
}),
[detail, marketAllBuckets, view.probabilities],
);
const probabilityRows =
marketContractRows.length > 0
? marketContractRows.slice(0, 8)
: modelProbabilityRows;
const topContractRow =
marketContractRows.length > 0
? marketContractRows.reduce((best, row) =>
row.probability > best.probability ? row : best,
)
: null;
const linkedMarketBucket = useMemo(() => { const linkedMarketBucket = useMemo(() => {
if (topContractRow?.marketBucket) return topContractRow.marketBucket;
if (topProbabilityTemp == null) return null; if (topProbabilityTemp == null) return null;
return findMarketBucketForDisplayTemp( return findMarketBucketForDisplayTemp(
marketAllBuckets, marketAllBuckets,
topProbabilityTemp, topProbabilityTemp,
detail, detail,
); );
}, [detail, marketAllBuckets, topProbabilityTemp]); }, [detail, marketAllBuckets, topContractRow, topProbabilityTemp]);
const priceAnalysis = marketScan?.price_analysis; const priceAnalysis = marketScan?.price_analysis;
const yesPriceView = priceAnalysis?.yes; const yesPriceView = priceAnalysis?.yes;
const noPriceView = priceAnalysis?.no; const noPriceView = priceAnalysis?.no;
@@ -826,13 +934,17 @@ export function ProbabilityDistribution({
null; null;
const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null; const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null;
const linkedContractLabel = const linkedContractLabel =
linkedMarketBucket?.label || topProbabilityLabel || null; topContractRow?.label ||
(linkedMarketBucket ? formatMarketBucketDisplayLabel(linkedMarketBucket, detail) : null) ||
topProbabilityLabel ||
null;
const aggregatedMarketProbability = getAggregatedModelProbabilityForMarketBucket( const aggregatedMarketProbability = getAggregatedModelProbabilityForMarketBucket(
view.probabilities || [], view.probabilities || [],
linkedMarketBucket, linkedMarketBucket,
detail, detail,
); );
const linkedMarketProbability = const linkedMarketProbability =
topContractRow?.probability ??
aggregatedMarketProbability ?? aggregatedMarketProbability ??
(topProbability?.probability != null ? Number(topProbability.probability) : null); (topProbability?.probability != null ? Number(topProbability.probability) : null);
const linkedMarketProbabilityText = toPercent(linkedMarketProbability); const linkedMarketProbabilityText = toPercent(linkedMarketProbability);
@@ -1060,19 +1172,12 @@ export function ProbabilityDistribution({
</em> </em>
</div> </div>
)} )}
{view.probabilities.length === 0 ? ( {probabilityRows.length === 0 ? (
<EmptyState text={t("section.noProb")} /> <EmptyState text={t("section.noProb")} />
) : ( ) : (
view.probabilities.slice(0, 6).map((bucket, index) => { probabilityRows.map((row, index) => {
const probability = Math.round( const probability = Math.round(Number(row.probability || 0) * 100);
Number(bucket.probability || 0) * 100, const rowMarketBucket = row.marketBucket;
);
const bucketTemp = getBucketTemp(bucket);
const rowMarketBucket = findMarketBucketForDisplayTemp(
marketAllBuckets,
bucketTemp,
detail,
);
const rowMarketPrice = const rowMarketPrice =
rowMarketBucket?.market_price ?? rowMarketBucket?.yes_buy ?? null; rowMarketBucket?.market_price ?? rowMarketBucket?.yes_buy ?? null;
const yesPriceText = toPriceCents(rowMarketPrice); const yesPriceText = toPriceCents(rowMarketPrice);
@@ -1081,14 +1186,13 @@ export function ProbabilityDistribution({
? `Market ref: ${yesPriceText || "--"}` ? `Market ref: ${yesPriceText || "--"}`
: `市场参考: ${yesPriceText || "--"}` : `市场参考: ${yesPriceText || "--"}`
: null; : null;
const bucketLabel = formatBucketDisplayLabel(bucket, detail);
return ( return (
<div <div
key={`${bucket.label || bucket.value || index}`} key={`${row.key || index}`}
className="prob-row" className="prob-row"
> >
<div className="prob-label">{bucketLabel}</div> <div className="prob-label">{row.label}</div>
<div className="prob-bar-track"> <div className="prob-bar-track">
<div <div
className={clsx("prob-bar-fill", `rank-${index}`)} className={clsx("prob-bar-fill", `rank-${index}`)}