Expand model summary probability rows
This commit is contained in:
@@ -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") &&
|
||||||
|
|||||||
Reference in New Issue
Block a user