From 1db473d60a1052dc9a218aed0f11a2db6335934e Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 25 Apr 2026 01:41:45 +0800 Subject: [PATCH] Fix opportunity group selection and probabilities --- .../components/dashboard/Dashboard.module.css | 7 + .../components/dashboard/OpportunityTable.tsx | 158 +++++++++++++++--- .../dashboard/ScanTerminalDashboard.tsx | 1 + 3 files changed, 146 insertions(+), 20 deletions(-) diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index b18aba15..24023e4e 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -9673,6 +9673,7 @@ background: linear-gradient(180deg, rgba(10, 21, 36, 0.94), rgba(8, 17, 29, 0.94)); } +.root :global(.scan-opportunity-group.selected), .root :global(.scan-opportunity-group:has(.scan-opportunity-item.selected)) { border-color: rgba(23, 217, 139, 0.42); box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.18); @@ -9791,6 +9792,7 @@ background: rgba(4, 12, 22, 0.48); color: #b8c9e1; text-align: left; + cursor: default; transition: background 0.18s ease, border-color 0.18s ease, @@ -11028,6 +11030,11 @@ border-color: rgba(10, 160, 100, 0.34); } +.root :global(.scan-terminal.light .scan-opportunity-group.selected) { + border-color: rgba(10, 160, 100, 0.34); + box-shadow: inset 0 0 0 1px rgba(10, 160, 100, 0.18); +} + .root :global(.scan-terminal.light .scan-opportunity-models span) { border-color: rgba(35, 72, 118, 0.1); background: rgba(255, 255, 255, 0.68); diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx index c902ee7d..084630ab 100644 --- a/frontend/components/dashboard/OpportunityTable.tsx +++ b/frontend/components/dashboard/OpportunityTable.tsx @@ -3,12 +3,15 @@ import React from "react"; import { useI18n } from "@/hooks/useI18n"; import type { + CityDetail, DistributionPreviewPoint, ScanOpportunityRow, } from "@/lib/dashboard-types"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; import { formatTemperatureValue, + getModelView, + getProbabilityView, normalizeTemperatureLabel, normalizeTemperatureSymbol, } from "@/lib/dashboard-utils"; @@ -24,6 +27,13 @@ function formatPercent(value?: number | null, signed = false) { return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`; } +function normalizeProbability(value?: number | null) { + if (value == null) return null; + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + return Math.max(0, Math.min(1, numeric > 1 ? numeric / 100 : numeric)); +} + function formatWindowMinutes(value: number | null | undefined, locale: string) { if (value == null || !Number.isFinite(Number(value))) return "--"; const minutes = Math.max(0, Math.round(Number(value))); @@ -199,6 +209,84 @@ function getEmosPeak(row: ScanOpportunityRow, tempSymbol?: string | null) { return { label: peakLabel, probability: peakProbability }; } +function getDetailForRow( + row: Pick, + cityDetailsByName?: Record, +) { + if (!cityDetailsByName) return null; + const rowKeys = [row.city, row.city_display_name, row.display_name] + .map((value) => String(value || "").trim().toLowerCase()) + .filter(Boolean); + return ( + Object.entries(cityDetailsByName).find(([name, detail]) => { + const detailKeys = [name, detail.name, detail.display_name] + .map((value) => String(value || "").trim().toLowerCase()) + .filter(Boolean); + return rowKeys.some((key) => detailKeys.includes(key)); + })?.[1] || null + ); +} + +function getDetailPeak( + detail: CityDetail | null, + targetDate?: string | null, + tempSymbol?: string | null, +) { + if (!detail) return null; + const view = getProbabilityView(detail, targetDate); + const buckets = Array.isArray(view.probabilitiesAll) + ? view.probabilitiesAll + : []; + const peak = [...buckets] + .filter((bucket) => normalizeProbability(bucket?.probability) != null) + .sort( + (a, b) => + Number(normalizeProbability(b?.probability)) - + Number(normalizeProbability(a?.probability)), + )[0]; + if (!peak) return null; + const peakValue = peak.value ?? null; + return { + label: + normalizeTemperatureLabel(peak.label || peak.bucket || peak.range, tempSymbol) || + (peakValue != null ? formatTemperatureValue(Number(peakValue), tempSymbol) : "--"), + probability: Number(normalizeProbability(peak.probability)) * 100, + }; +} + +function getDetailBucketEventProbability( + detail: CityDetail | null, + row: ScanOpportunityRow, + tempSymbol?: string | null, +) { + if (!detail) return null; + const view = getProbabilityView(detail, row.selected_date || row.local_date); + const buckets = Array.isArray(view.probabilitiesAll) + ? view.probabilitiesAll + : []; + if (!buckets.length) return null; + const targetLabel = normalizeTemperatureLabel(row.target_label, tempSymbol); + const targetValue = + row.target_value ?? row.target_threshold ?? row.target_lower ?? row.target_upper ?? null; + const matched = + buckets.find((bucket) => { + const label = normalizeTemperatureLabel( + bucket.label || bucket.bucket || bucket.range, + tempSymbol, + ); + return Boolean(targetLabel && label && label === targetLabel); + }) || + buckets.find((bucket) => { + const value = bucket.value; + return ( + value != null && + targetValue != null && + Math.abs(Number(value) - Number(targetValue)) < 0.01 + ); + }); + return normalizeProbability(matched?.probability); +} + type OpportunityGroup = { key: string; cityName: string; @@ -213,10 +301,15 @@ type OpportunityGroup = { rows: ScanOpportunityRow[]; }; -function buildOpportunityGroups(rows: ScanOpportunityRow[], locale: string): OpportunityGroup[] { +function buildOpportunityGroups( + rows: ScanOpportunityRow[], + locale: string, + cityDetailsByName?: Record, +): OpportunityGroup[] { const groups = new Map(); for (const row of rows) { const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol); + const detail = getDetailForRow(row, cityDetailsByName); const cityName = getLocalizedCityName( row.city, row.city_display_name || row.display_name || row.city, @@ -224,7 +317,9 @@ function buildOpportunityGroups(rows: ScanOpportunityRow[], locale: string): Opp ); const date = row.selected_date || row.local_date || ""; const key = `${row.city || cityName}|${date}`; - const peak = getEmosPeak(row, tempSymbol); + const peak = getDetailPeak(detail, date, tempSymbol) || getEmosPeak(row, tempSymbol); + const modelView = detail ? getModelView(detail, date) : null; + const debPrediction = modelView?.deb ?? row.deb_prediction ?? null; const existing = groups.get(key); if (!existing) { groups.set(key, { @@ -233,8 +328,8 @@ function buildOpportunityGroups(rows: ScanOpportunityRow[], locale: string): Opp date, tempSymbol, debLabel: - row.deb_prediction != null - ? formatTemperatureValue(Number(row.deb_prediction), tempSymbol, { digits: 1 }) + debPrediction != null + ? formatTemperatureValue(Number(debPrediction), tempSymbol, { digits: 1 }) : "--", peakLabel: peak.label, peakProbability: peak.probability, @@ -269,6 +364,7 @@ export const OpportunityTable = React.memo(function OpportunityTable({ loading, selectedRowId, onSelectRow, + cityDetailsByName, }: { rows: ScanOpportunityRow[]; status?: string | null; @@ -277,6 +373,7 @@ export const OpportunityTable = React.memo(function OpportunityTable({ loading?: boolean; selectedRowId?: string | null; onSelectRow?: (row: ScanOpportunityRow) => void; + cityDetailsByName?: Record; }) { const { locale } = useI18n(); const isEn = locale === "en-US"; @@ -284,8 +381,8 @@ export const OpportunityTable = React.memo(function OpportunityTable({ const scanInProgress = loading || status === "partial" || status === "scanning"; const groups = React.useMemo( - () => buildOpportunityGroups(rows, locale), - [rows, locale], + () => buildOpportunityGroups(rows, locale, cityDetailsByName), + [rows, locale, cityDetailsByName], ); if (!hasRows) { @@ -330,8 +427,13 @@ export const OpportunityTable = React.memo(function OpportunityTable({ ) : null}
- {groups.map((group) => ( -
+ {groups.map((group) => { + const groupSelected = group.rows.some((row) => row.id === selectedRowId); + return ( +
+
); })} - ))} + ); + })} ); diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 922634ad..f25854e5 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -668,6 +668,7 @@ function ScanTerminalScreen() { loading={loading} selectedRowId={selectedRowId} onSelectRow={handleSelectRow} + cityDetailsByName={store.cityDetailsByName} /> );