Fix opportunity group selection and probabilities

This commit is contained in:
2569718930@qq.com
2026-04-25 01:41:45 +08:00
parent df6a2c73ed
commit 1db473d60a
3 changed files with 146 additions and 20 deletions
@@ -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);
@@ -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<ScanOpportunityRow, "city" | "city_display_name" | "display_name">,
cityDetailsByName?: Record<string, CityDetail>,
) {
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<string, CityDetail>,
): OpportunityGroup[] {
const groups = new Map<string, OpportunityGroup>();
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<string, CityDetail>;
}) {
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({
</div>
) : null}
<div className="scan-table-body scan-opportunity-groups">
{groups.map((group) => (
<section key={group.key} className="scan-opportunity-group">
{groups.map((group) => {
const groupSelected = group.rows.some((row) => row.id === selectedRowId);
return (
<section
key={group.key}
className={`scan-opportunity-group ${groupSelected ? "selected" : ""}`}
>
<button
type="button"
className="scan-opportunity-group-head"
@@ -371,24 +473,39 @@ export const OpportunityTable = React.memo(function OpportunityTable({
<div className="scan-opportunity-items">
{group.rows.map((row, rowIndex) => {
const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
const selected = selectedRowId === row.id;
const side = String(row.side || "").toLowerCase();
const detail = getDetailForRow(row, cityDetailsByName);
const detailEventProbability = getDetailBucketEventProbability(
detail,
row,
tempSymbol,
);
const modelProbability =
row.raw_model_event_probability != null
? row.raw_model_event_probability * 100
detailEventProbability != null
? (side === "no" ? 1 - detailEventProbability : detailEventProbability) * 100
: row.model_probability != null
? Number(row.model_probability) * 100
: row.raw_model_event_probability != null
? (side === "no"
? 1 - Number(row.raw_model_event_probability)
: Number(row.raw_model_event_probability)) * 100
: row.model_event_probability != null
? row.model_event_probability * 100
? (side === "no"
? 1 - Number(row.model_event_probability)
: Number(row.model_event_probability)) * 100
: null;
const edgePercent =
modelProbability != null && row.ask != null
? modelProbability - Number(row.ask) * 100
: row.edge_percent;
const modelLabel = "EMOS";
const priceLabel = side === "no" ? "NO" : isEn ? "Market" : "市场";
const edgePositive = Number(row.edge_percent || 0) >= 0;
const edgePositive = Number(edgePercent || 0) >= 0;
const aiMeta = getAiMeta(row, locale);
return (
<button
<div
key={row.id}
type="button"
className={`scan-opportunity-item ${selected ? "selected" : ""} ${aiMeta ? `ai-${aiMeta.tone}` : ""}`}
onClick={() => onSelectRow?.(row)}
className={`scan-opportunity-item ${aiMeta ? `ai-${aiMeta.tone}` : ""}`}
>
<span className="scan-opportunity-branch" aria-hidden="true">
<i />
@@ -409,7 +526,7 @@ export const OpportunityTable = React.memo(function OpportunityTable({
<span className="scan-opportunity-stat edge">
<small>edge</small>
<b className={edgePositive ? "positive" : "negative"}>
{formatPercent(row.edge_percent, true)}
{formatPercent(edgePercent, true)}
</b>
</span>
{aiMeta ? (
@@ -418,12 +535,13 @@ export const OpportunityTable = React.memo(function OpportunityTable({
{aiMeta.reason ? <small>{aiMeta.reason}</small> : null}
</span>
) : null}
</button>
</div>
);
})}
</div>
</section>
))}
);
})}
</div>
</div>
);
@@ -668,6 +668,7 @@ function ScanTerminalScreen() {
loading={loading}
selectedRowId={selectedRowId}
onSelectRow={handleSelectRow}
cityDetailsByName={store.cityDetailsByName}
/>
</>
);