Expand model summary probability rows

This commit is contained in:
2569718930@qq.com
2026-06-29 22:46:52 +08:00
parent bbf2db4da1
commit 515c7c1d40
2 changed files with 164 additions and 61 deletions
@@ -1,8 +1,8 @@
"use client"; "use client";
import clsx from "clsx"; import clsx from "clsx";
import { Search, Table2 } from "lucide-react"; import { ChevronRight, Search, Table2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { import {
MODEL_SUMMARY_MODEL_COLUMNS, MODEL_SUMMARY_MODEL_COLUMNS,
@@ -32,6 +32,8 @@ const SUMMARY_TEXT = {
region: { en: "Region", zh: "区域" }, region: { en: "Region", zh: "区域" },
localTime: { en: "Local Time", zh: "当地时间" }, localTime: { en: "Local Time", zh: "当地时间" },
gaussianMu: { en: "Gaussian μ", zh: "高斯 μ" }, gaussianMu: { en: "Gaussian μ", zh: "高斯 μ" },
detailedProbability: { en: "Detailed Probability", zh: "详细概率分布" },
noProbabilityDistribution: { en: "No probability distribution", zh: "暂无详细概率" },
marketMatch: { en: "Market Match", zh: "市场匹配" }, marketMatch: { en: "Market Match", zh: "市场匹配" },
median: { en: "Median", zh: "模型中位数" }, median: { en: "Median", zh: "模型中位数" },
spread: { en: "Spread", zh: "分歧范围" }, spread: { en: "Spread", zh: "分歧范围" },
@@ -40,6 +42,8 @@ const SUMMARY_TEXT = {
total: { en: "rows", zh: "行" }, total: { en: "rows", zh: "行" },
} as const; } as const;
const MODEL_SUMMARY_COLUMN_COUNT = MODEL_SUMMARY_MODEL_COLUMNS.length + 8;
function copy(key: keyof typeof SUMMARY_TEXT, isEn: boolean) { function copy(key: keyof typeof SUMMARY_TEXT, isEn: boolean) {
return SUMMARY_TEXT[key][isEn ? "en" : "zh"]; return SUMMARY_TEXT[key][isEn ? "en" : "zh"];
} }
@@ -101,71 +105,133 @@ function ModelSummaryRowView({
row, row,
nowMs, nowMs,
isEn, isEn,
expanded,
onToggle,
}: { }: {
row: ModelSummaryRow; row: ModelSummaryRow;
nowMs: number | null; nowMs: number | null;
isEn: boolean; isEn: boolean;
expanded: boolean;
onToggle: () => void;
}) { }) {
return ( return (
<tr className="group border-b border-slate-100 hover:bg-blue-50/40"> <>
<th <tr className="group border-b border-slate-100 hover:bg-blue-50/40">
scope="row" <th
className="sticky left-0 z-10 w-[160px] min-w-[160px] max-w-[160px] border-r border-slate-200 bg-white px-3 py-2 text-left align-middle text-xs font-black text-slate-900 group-hover:bg-blue-50" scope="row"
> className="sticky left-0 z-10 w-[160px] min-w-[160px] max-w-[160px] border-r border-slate-200 bg-white px-2 py-2 text-left align-middle text-xs font-black text-slate-900 group-hover:bg-blue-50"
<span className="block truncate">{row.cityName}</span> >
</th> <button
<td className="min-w-[96px] max-w-[112px] px-2 py-2 text-xs font-semibold text-slate-600"> type="button"
<span className="block truncate">{row.regionLabel}</span> aria-expanded={expanded}
</td> onClick={onToggle}
<td className="min-w-[96px] px-3 py-2 text-right font-mono text-[11px] font-bold text-slate-700"> className="flex w-full min-w-0 items-center gap-1.5 rounded px-1 py-0.5 text-left outline-none transition hover:bg-blue-100/70 focus-visible:ring-2 focus-visible:ring-blue-300"
{formatModelSummaryLocalTime(row, nowMs)} >
</td> <ChevronRight
<td className="min-w-[90px] px-3 py-2 text-right"> size={13}
<TemperatureCell value={row.debPrediction} symbol={row.tempSymbol} emphasis="deb" /> className={clsx(
</td> "shrink-0 text-slate-400 transition-transform",
{MODEL_SUMMARY_MODEL_COLUMNS.map((column) => ( expanded && "rotate-90 text-blue-600",
<td key={column.key} className="min-w-[92px] px-3 py-2 text-right"> )}
<TemperatureCell value={row.models[column.key]} symbol={row.tempSymbol} /> />
<span className="block truncate">{row.cityName}</span>
</button>
</th>
<td className="min-w-[96px] max-w-[112px] px-2 py-2 text-xs font-semibold text-slate-600">
<span className="block truncate">{row.regionLabel}</span>
</td> </td>
))} <td className="min-w-[96px] px-3 py-2 text-right font-mono text-[11px] font-bold text-slate-700">
<td className="min-w-[110px] px-3 py-2 text-right"> {formatModelSummaryLocalTime(row, nowMs)}
<TemperatureCell value={row.modelMedian} symbol={row.tempSymbol} emphasis="median" /> </td>
</td> <td className="min-w-[90px] px-3 py-2 text-right">
<td className="min-w-[100px] px-3 py-2 text-right"> <TemperatureCell value={row.debPrediction} symbol={row.tempSymbol} emphasis="deb" />
<TemperatureCell value={row.modelSpread} symbol={row.tempSymbol} /> </td>
</td> {MODEL_SUMMARY_MODEL_COLUMNS.map((column) => (
<td className="min-w-[92px] px-3 py-2 text-right"> <td key={column.key} className="min-w-[92px] px-3 py-2 text-right">
<TemperatureCell value={row.gaussianMu} symbol={row.tempSymbol} emphasis="median" /> <TemperatureCell value={row.models[column.key]} symbol={row.tempSymbol} />
</td> </td>
<td className="min-w-[420px] max-w-[520px] px-3 py-1.5 text-left"> ))}
{row.marketMatches.length ? ( <td className="min-w-[110px] px-3 py-2 text-right">
<div className="flex flex-wrap items-center gap-1"> <TemperatureCell value={row.modelMedian} symbol={row.tempSymbol} emphasis="median" />
{row.marketMatches.map((match) => { </td>
return ( <td className="min-w-[100px] px-3 py-2 text-right">
<a <TemperatureCell value={row.modelSpread} symbol={row.tempSymbol} />
key={match.key} </td>
href={match.marketUrl || undefined} <td className="min-w-[92px] px-3 py-2 text-right">
target={match.marketUrl ? "_blank" : undefined} <TemperatureCell value={row.gaussianMu} symbol={row.tempSymbol} emphasis="median" />
rel={match.marketUrl ? "noreferrer" : undefined} </td>
className={clsx( <td className="min-w-[420px] max-w-[520px] px-3 py-1.5 text-left">
"inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[10px] font-bold tabular-nums", {row.marketMatches.length ? (
(match.modelProbability ?? 0) >= 0.2 <div className="flex flex-wrap items-center gap-1">
? "border-emerald-200 bg-emerald-50 text-emerald-800" {row.marketMatches.map((match) => {
: "border-slate-200 bg-slate-50 text-slate-600", return (
)} <a
title={`${match.label} model ${formatModelSummaryProbability(match.modelProbability)}`} key={match.key}
> href={match.marketUrl || undefined}
<span className="font-black">{match.label}</span> target={match.marketUrl ? "_blank" : undefined}
<span>{isEn ? "M" : "模"} {formatModelSummaryProbability(match.modelProbability)}</span> rel={match.marketUrl ? "noreferrer" : undefined}
</a> className={clsx(
); "inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[10px] font-bold tabular-nums",
})} (match.modelProbability ?? 0) >= 0.2
</div> ? "border-emerald-200 bg-emerald-50 text-emerald-800"
) : ( : "border-slate-200 bg-slate-50 text-slate-600",
<span className="font-mono text-xs font-semibold text-slate-300"></span> )}
)} title={`${match.label} model ${formatModelSummaryProbability(match.modelProbability)}`}
</td> >
</tr> <span className="font-black">{match.label}</span>
<span>{isEn ? "M" : "模"} {formatModelSummaryProbability(match.modelProbability)}</span>
</a>
);
})}
</div>
) : (
<span className="font-mono text-xs font-semibold text-slate-300"></span>
)}
</td>
</tr>
{expanded ? (
<tr className="border-b border-blue-100 bg-blue-50/35">
<td colSpan={MODEL_SUMMARY_COLUMN_COUNT} className="px-3 py-2">
<div className="flex flex-col gap-2 md:flex-row md:items-start">
<div className="flex w-full shrink-0 items-center gap-2 text-xs md:w-[220px]">
<span className="font-black text-slate-800">
{copy("detailedProbability", isEn)}
</span>
<span className="font-mono text-[11px] font-bold text-violet-700">
μ {formatModelSummaryTemp(row.gaussianMu, row.tempSymbol)}
</span>
</div>
{row.probabilityBuckets.length ? (
<div className="flex flex-1 flex-wrap items-center gap-1.5">
{row.probabilityBuckets.map((bucket) => {
const isTopBucket = bucket.key === row.topProbabilityBucketKey;
return (
<span
key={bucket.key}
className={clsx(
"inline-flex items-center gap-1 rounded border px-2 py-1 font-mono text-[10px] font-bold tabular-nums",
isTopBucket
? "border-violet-300 bg-violet-100 text-violet-800 shadow-sm"
: "border-slate-200 bg-white text-slate-600",
)}
title={bucket.label}
>
<span>{bucket.label}</span>
<span>{formatModelSummaryProbability(bucket.probability)}</span>
</span>
);
})}
</div>
) : (
<span className="text-xs font-bold text-slate-400">
{copy("noProbabilityDistribution", isEn)}
</span>
)}
</div>
</td>
</tr>
) : null}
</>
); );
} }
@@ -215,6 +281,7 @@ export function ModelSummaryDashboard({
const [debOnly, setDebOnly] = useState(false); const [debOnly, setDebOnly] = useState(false);
const [wideSpreadOnly, setWideSpreadOnly] = useState(false); const [wideSpreadOnly, setWideSpreadOnly] = useState(false);
const [nowMs, setNowMs] = useState<number | null>(null); const [nowMs, setNowMs] = useState<number | null>(null);
const [expandedCityKeys, setExpandedCityKeys] = useState<Set<string>>(() => new Set());
const lastGoodSummaryRowsRef = useRef<ModelSummaryRow[]>([]); const lastGoodSummaryRowsRef = useRef<ModelSummaryRow[]>([]);
useEffect(() => { useEffect(() => {
@@ -250,6 +317,34 @@ export function ModelSummaryDashboard({
[summaryRows, query, debOnly, wideSpreadOnly], [summaryRows, query, debOnly, wideSpreadOnly],
); );
const toggleExpandedCity = useCallback((cityKey: string) => {
setExpandedCityKeys((current) => {
const next = new Set(current);
if (next.has(cityKey)) {
next.delete(cityKey);
} else {
next.add(cityKey);
}
return next;
});
}, []);
useEffect(() => {
const visibleCityKeys = new Set(visibleRows.map((row) => row.cityKey));
setExpandedCityKeys((current) => {
let changed = false;
const next = new Set<string>();
current.forEach((cityKey) => {
if (visibleCityKeys.has(cityKey)) {
next.add(cityKey);
} else {
changed = true;
}
});
return changed ? next : current;
});
}, [visibleRows]);
return ( return (
<section className="flex min-h-0 flex-1 flex-col overflow-hidden rounded border border-[#d2d9e2] bg-white shadow-sm"> <section className="flex min-h-0 flex-1 flex-col overflow-hidden rounded border border-[#d2d9e2] bg-white shadow-sm">
<header className="flex shrink-0 flex-col gap-3 border-b border-slate-200 bg-white px-3 py-3 md:flex-row md:items-center md:justify-between"> <header className="flex shrink-0 flex-col gap-3 border-b border-slate-200 bg-white px-3 py-3 md:flex-row md:items-center md:justify-between">
@@ -331,6 +426,8 @@ export function ModelSummaryDashboard({
row={row} row={row}
nowMs={nowMs} nowMs={nowMs}
isEn={isEn} isEn={isEn}
expanded={expandedCityKeys.has(row.cityKey)}
onToggle={() => toggleExpandedCity(row.cityKey)}
/> />
))} ))}
</tbody> </tbody>
@@ -221,6 +221,13 @@ export function runTests() {
modelSummarySource.includes("hasModelSummaryForecastData") && modelSummarySource.includes("hasModelSummaryForecastData") &&
modelSummarySource.includes("Gaussian μ") && modelSummarySource.includes("Gaussian μ") &&
modelSummarySource.includes("高斯 μ") && modelSummarySource.includes("高斯 μ") &&
modelSummarySource.includes("Detailed Probability") &&
modelSummarySource.includes("详细概率分布") &&
modelSummarySource.includes("expandedCityKeys") &&
modelSummarySource.includes("toggleExpandedCity") &&
modelSummarySource.includes("aria-expanded") &&
modelSummarySource.includes("ChevronRight") &&
modelSummarySource.includes("probabilityBuckets.map") &&
modelSummarySource.includes("Market Match") && modelSummarySource.includes("Market Match") &&
modelSummarySource.includes("市场匹配") && modelSummarySource.includes("市场匹配") &&
modelSummarySource.includes("marketMatches.map") && modelSummarySource.includes("marketMatches.map") &&
@@ -228,7 +235,6 @@ export function runTests() {
!modelSummarySource.includes("marketProbability") && !modelSummarySource.includes("marketProbability") &&
!modelSummarySource.includes("edgePercent") && !modelSummarySource.includes("edgePercent") &&
!modelSummarySource.includes("Probability Distribution") && !modelSummarySource.includes("Probability Distribution") &&
!modelSummarySource.includes("概率分布") &&
!modelSummarySource.includes("probabilityColumns") && !modelSummarySource.includes("probabilityColumns") &&
modelSummarySource.includes("min-w-[96px]") && modelSummarySource.includes("min-w-[96px]") &&
modelSummarySource.includes("Local Time") && modelSummarySource.includes("Local Time") &&