feat: add PanelSections component for dashboard weather data visualization and market bucket analysis
This commit is contained in:
@@ -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}`)}
|
||||||
|
|||||||
Reference in New Issue
Block a user