From c346fae351dda119bd83db2cd4e8a56bfa63b571 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 17 Apr 2026 00:48:12 +0800 Subject: [PATCH] Add rounded model-vote baseline to probability distribution --- .../components/dashboard/Dashboard.module.css | 110 ++++++++++++++- .../components/dashboard/PanelSections.tsx | 130 ++++++++++++++++++ 2 files changed, 233 insertions(+), 7 deletions(-) diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 87a8cc9c..ea38f47a 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -1001,6 +1001,87 @@ border: 1px solid rgba(251, 113, 133, 0.26); } +.root :global(.prob-model-vote) { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 8px; + padding: 10px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 8px; + background: rgba(15, 23, 42, 0.34); +} + +.root :global(.prob-model-vote-head) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.root :global(.prob-model-vote-head span) { + color: var(--text-primary); + font-size: 12px; + font-weight: 800; +} + +.root :global(.prob-model-vote-head em) { + color: var(--text-muted); + font-size: 11px; + font-style: normal; +} + +.root :global(.prob-model-vote-grid) { + display: grid; + gap: 6px; +} + +.root :global(.prob-model-vote-row) { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.root :global(.prob-model-vote-row > span) { + color: var(--text-primary); + font-size: 12px; + font-weight: 800; + text-align: right; +} + +.root :global(.prob-model-vote-track) { + position: relative; + height: 18px; + overflow: hidden; + border-radius: 6px; + background: rgba(148, 163, 184, 0.1); +} + +.root :global(.prob-model-vote-fill) { + height: 100%; + border-radius: 6px; + background: linear-gradient(90deg, rgba(20, 184, 166, 0.62), rgba(34, 211, 238, 0.88)); +} + +.root :global(.prob-model-vote-track strong) { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + color: var(--text-primary); + font-size: 11px; + line-height: 1; + text-shadow: 0 1px 2px rgba(2, 6, 23, 0.85); +} + +.root :global(.prob-model-vote p) { + margin: 0; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; +} + /* ── Model Bars ── */ .root :global(.model-bars) { display: flex; @@ -1125,13 +1206,6 @@ border-radius: 6px; background: linear-gradient(90deg, rgba(14, 165, 233, 0.72), rgba(34, 211, 238, 0.92)); transition: width 0.6s ease-out; - display: flex; - align-items: center; - justify-content: flex-end; - padding-right: 8px; - font-size: 11px; - font-weight: 600; - color: white; } .root :global(.model-bar-fill.deb) { @@ -1139,6 +1213,28 @@ box-shadow: 0 0 8px rgba(34, 211, 238, 0.3); } +.root :global(.model-bar-value) { + position: absolute; + top: 50%; + right: 8px; + z-index: 3; + transform: translateY(-50%); + color: var(--text-primary); + font-size: 11px; + font-weight: 800; + line-height: 1; + max-width: calc(100% - 12px); + overflow: visible; + pointer-events: none; + text-align: right; + text-shadow: 0 1px 2px rgba(2, 6, 23, 0.9); + white-space: nowrap; +} + +.root :global(.model-bar-value.deb) { + color: #ecfeff; +} + .root :global(.model-deb-line) { position: absolute; top: 0; diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index 95182ff7..85e1ecd5 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -174,6 +174,92 @@ function formatModelMetaLine( 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(); + 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 getMarketNoPrice(scan?: MarketScan | null) { if (scan?.no_buy != null) { const direct = Number(scan.no_buy); @@ -494,6 +580,10 @@ export function ProbabilityDistribution({ const marketYesText = toPercent(marketYesPrice); const marketNoText = toPercent(marketNoPrice); const isToday = !targetDate || targetDate === detail.local_date; + const modelVoteView = useMemo( + () => getRoundedModelVoteDistribution(detail, targetDate), + [detail, targetDate], + ); const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : []; const sortedMarketTopBuckets = useMemo(() => { const sorted = [...marketTopBuckets].sort( @@ -549,6 +639,46 @@ export function ProbabilityDistribution({ : `市场概率(该温度桶): ${marketYesText}`} )} + {modelVoteView.rows.length > 0 && ( +
+
+ + {locale === "en-US" + ? "Rounded model-vote baseline" + : "模型四舍五入票数基线"} + + + {locale === "en-US" + ? `${modelVoteView.total} sources after family dedup` + : `家族去重后 ${modelVoteView.total} 个来源`} + +
+
+ {modelVoteView.rows.slice(0, 4).map((row) => ( +
+ + {row.value} + {detail.temp_symbol} + +
+
+ + {row.count}/{modelVoteView.total} + +
+
+ ))} +
+

+ {locale === "en-US" + ? "This is a transparent model-count baseline, not the calibrated settlement probability." + : "这是透明模型票数基线,不等同于经过实测下限与历史 MAE 校准后的结算概率。"} +

+
+ )} {useMarketTopBuckets ? ( sortedMarketTopBuckets.map((bucket, index) => { const probability = Math.round(