Render probability distribution per city row
This commit is contained in:
@@ -6,14 +6,12 @@ import { 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,
|
||||||
buildModelSummaryProbabilityColumns,
|
|
||||||
buildModelSummaryRows,
|
buildModelSummaryRows,
|
||||||
filterModelSummaryRows,
|
filterModelSummaryRows,
|
||||||
formatModelSummaryProbability,
|
formatModelSummaryProbability,
|
||||||
formatModelSummaryLocalTime,
|
formatModelSummaryLocalTime,
|
||||||
formatModelSummaryTemp,
|
formatModelSummaryTemp,
|
||||||
hasModelSummaryForecastData,
|
hasModelSummaryForecastData,
|
||||||
type ModelSummaryProbabilityColumn,
|
|
||||||
type ModelSummaryRow,
|
type ModelSummaryRow,
|
||||||
} from "@/lib/model-summary";
|
} from "@/lib/model-summary";
|
||||||
|
|
||||||
@@ -34,6 +32,7 @@ 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: "高斯 μ" },
|
||||||
|
probabilityDistribution: { en: "Probability Distribution", zh: "概率分布" },
|
||||||
median: { en: "Median", zh: "模型中位数" },
|
median: { en: "Median", zh: "模型中位数" },
|
||||||
spread: { en: "Spread", zh: "分歧范围" },
|
spread: { en: "Spread", zh: "分歧范围" },
|
||||||
empty: { en: "No model summary rows match the current filters.", zh: "当前筛选下没有模型汇总数据。" },
|
empty: { en: "No model summary rows match the current filters.", zh: "当前筛选下没有模型汇总数据。" },
|
||||||
@@ -101,11 +100,9 @@ function FilterToggle({
|
|||||||
function ModelSummaryRowView({
|
function ModelSummaryRowView({
|
||||||
row,
|
row,
|
||||||
nowMs,
|
nowMs,
|
||||||
probabilityColumns,
|
|
||||||
}: {
|
}: {
|
||||||
row: ModelSummaryRow;
|
row: ModelSummaryRow;
|
||||||
nowMs: number | null;
|
nowMs: number | null;
|
||||||
probabilityColumns: ModelSummaryProbabilityColumn[];
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<tr className="group border-b border-slate-100 hover:bg-blue-50/40">
|
<tr className="group border-b border-slate-100 hover:bg-blue-50/40">
|
||||||
@@ -138,26 +135,32 @@ function ModelSummaryRowView({
|
|||||||
<td className="min-w-[92px] px-3 py-2 text-right">
|
<td className="min-w-[92px] px-3 py-2 text-right">
|
||||||
<TemperatureCell value={row.gaussianMu} symbol={row.tempSymbol} emphasis="median" />
|
<TemperatureCell value={row.gaussianMu} symbol={row.tempSymbol} emphasis="median" />
|
||||||
</td>
|
</td>
|
||||||
{probabilityColumns.map((column) => {
|
<td className="min-w-[420px] max-w-[520px] px-3 py-1.5 text-left">
|
||||||
const bucket = row.probabilityBucketMap[column.key] || null;
|
{row.probabilityBuckets.length ? (
|
||||||
const isTopBucket = bucket?.key === row.topProbabilityBucketKey;
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
return (
|
{row.probabilityBuckets.map((bucket) => {
|
||||||
<td key={column.key} className="min-w-[86px] px-2 py-2 text-right">
|
const isTopBucket = bucket.key === row.topProbabilityBucketKey;
|
||||||
<span
|
return (
|
||||||
className={clsx(
|
<span
|
||||||
"inline-flex min-w-[42px] justify-end rounded px-1.5 py-0.5 font-mono tabular-nums",
|
key={bucket.key}
|
||||||
bucket == null
|
className={clsx(
|
||||||
? "font-semibold text-slate-300"
|
"inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[10px] tabular-nums",
|
||||||
: isTopBucket
|
isTopBucket
|
||||||
? "bg-violet-50 font-black text-violet-800 ring-1 ring-violet-200"
|
? "border-violet-200 bg-violet-50 font-black text-violet-800"
|
||||||
: "font-bold text-violet-700",
|
: "border-slate-200 bg-slate-50 font-bold text-violet-700",
|
||||||
)}
|
)}
|
||||||
>
|
title={bucket.label}
|
||||||
{formatModelSummaryProbability(bucket?.probability)}
|
>
|
||||||
</span>
|
<span>{bucket.label}</span>
|
||||||
</td>
|
<strong>{formatModelSummaryProbability(bucket.probability)}</strong>
|
||||||
);
|
</span>
|
||||||
})}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-xs font-semibold text-slate-300">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -233,11 +236,6 @@ export function ModelSummaryDashboard({
|
|||||||
}
|
}
|
||||||
}, [incomingSummaryRows, incomingHasForecastData]);
|
}, [incomingSummaryRows, incomingHasForecastData]);
|
||||||
|
|
||||||
const probabilityColumns = useMemo(
|
|
||||||
() => buildModelSummaryProbabilityColumns(summaryRows),
|
|
||||||
[summaryRows],
|
|
||||||
);
|
|
||||||
|
|
||||||
const visibleRows = useMemo(
|
const visibleRows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
filterModelSummaryRows(summaryRows, {
|
filterModelSummaryRows(summaryRows, {
|
||||||
@@ -295,7 +293,7 @@ export function ModelSummaryDashboard({
|
|||||||
<div className="min-h-0 flex-1 overflow-auto">
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
<table
|
<table
|
||||||
className="w-full border-collapse text-xs"
|
className="w-full border-collapse text-xs"
|
||||||
style={{ minWidth: `${1560 + probabilityColumns.length * 86}px` }}
|
style={{ minWidth: "1940px" }}
|
||||||
>
|
>
|
||||||
<thead className="sticky top-0 z-20 bg-slate-50 text-[10px] font-black uppercase tracking-wide text-slate-500 shadow-[0_1px_0_0_#e2e8f0]">
|
<thead className="sticky top-0 z-20 bg-slate-50 text-[10px] font-black uppercase tracking-wide text-slate-500 shadow-[0_1px_0_0_#e2e8f0]">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -317,15 +315,9 @@ export function ModelSummaryDashboard({
|
|||||||
<th className="min-w-[92px] px-3 py-2 text-right text-violet-700">
|
<th className="min-w-[92px] px-3 py-2 text-right text-violet-700">
|
||||||
{copy("gaussianMu", isEn)}
|
{copy("gaussianMu", isEn)}
|
||||||
</th>
|
</th>
|
||||||
{probabilityColumns.map((column) => (
|
<th className="min-w-[420px] max-w-[520px] px-3 py-2 text-left text-violet-700">
|
||||||
<th
|
{copy("probabilityDistribution", isEn)}
|
||||||
key={column.key}
|
</th>
|
||||||
className="min-w-[86px] px-2 py-2 text-right text-violet-700"
|
|
||||||
title={column.label}
|
|
||||||
>
|
|
||||||
{column.label}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100 bg-white">
|
<tbody className="divide-y divide-slate-100 bg-white">
|
||||||
@@ -334,7 +326,6 @@ export function ModelSummaryDashboard({
|
|||||||
key={row.cityKey}
|
key={row.cityKey}
|
||||||
row={row}
|
row={row}
|
||||||
nowMs={nowMs}
|
nowMs={nowMs}
|
||||||
probabilityColumns={probabilityColumns}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
MODEL_SUMMARY_MODEL_COLUMNS,
|
MODEL_SUMMARY_MODEL_COLUMNS,
|
||||||
buildModelSummaryProbabilityColumns,
|
|
||||||
buildModelSummaryRows,
|
buildModelSummaryRows,
|
||||||
filterModelSummaryRows,
|
filterModelSummaryRows,
|
||||||
formatModelSummaryProbability,
|
formatModelSummaryProbability,
|
||||||
@@ -132,12 +131,6 @@ export function runTests() {
|
|||||||
parisRow.topProbabilityBucketKey === "31.5-32.5°C",
|
parisRow.topProbabilityBucketKey === "31.5-32.5°C",
|
||||||
"model summary should map probability buckets and identify the top bucket",
|
"model summary should map probability buckets and identify the top bucket",
|
||||||
);
|
);
|
||||||
const probabilityColumns = buildModelSummaryProbabilityColumns(summaryRows);
|
|
||||||
assert(
|
|
||||||
probabilityColumns.map((column) => column.key).join("|") ===
|
|
||||||
"30.5-31.5°C|31.5-32.5°C|32.5-33.5°C|34.5-35.5°C|35.5-36.5°C",
|
|
||||||
"model summary should expose dynamic probability bucket columns sorted by temperature",
|
|
||||||
);
|
|
||||||
assert(formatModelSummaryProbability(null) === "—", "missing probability should render as an em dash");
|
assert(formatModelSummaryProbability(null) === "—", "missing probability should render as an em dash");
|
||||||
assert(formatModelSummaryProbability(0.424) === "42%", "probability buckets should render as rounded percentages");
|
assert(formatModelSummaryProbability(0.424) === "42%", "probability buckets should render as rounded percentages");
|
||||||
assert(formatModelSummaryTemp(null, "°C") === "—", "missing model temperatures should render as an em dash");
|
assert(formatModelSummaryTemp(null, "°C") === "—", "missing model temperatures should render as an em dash");
|
||||||
@@ -204,10 +197,13 @@ export function runTests() {
|
|||||||
modelSummarySource.includes("MODEL_SUMMARY_MODEL_COLUMNS") &&
|
modelSummarySource.includes("MODEL_SUMMARY_MODEL_COLUMNS") &&
|
||||||
modelSummarySource.includes("lastGoodSummaryRowsRef") &&
|
modelSummarySource.includes("lastGoodSummaryRowsRef") &&
|
||||||
modelSummarySource.includes("hasModelSummaryForecastData") &&
|
modelSummarySource.includes("hasModelSummaryForecastData") &&
|
||||||
modelSummarySource.includes("buildModelSummaryProbabilityColumns") &&
|
|
||||||
modelSummarySource.includes("Gaussian μ") &&
|
modelSummarySource.includes("Gaussian μ") &&
|
||||||
modelSummarySource.includes("高斯 μ") &&
|
modelSummarySource.includes("高斯 μ") &&
|
||||||
|
modelSummarySource.includes("Probability Distribution") &&
|
||||||
|
modelSummarySource.includes("概率分布") &&
|
||||||
modelSummarySource.includes("topProbabilityBucketKey") &&
|
modelSummarySource.includes("topProbabilityBucketKey") &&
|
||||||
|
modelSummarySource.includes("probabilityBuckets.map") &&
|
||||||
|
!modelSummarySource.includes("probabilityColumns") &&
|
||||||
modelSummarySource.includes("min-w-[96px]") &&
|
modelSummarySource.includes("min-w-[96px]") &&
|
||||||
modelSummarySource.includes("Local Time") &&
|
modelSummarySource.includes("Local Time") &&
|
||||||
modelSummarySource.includes("当地时间") &&
|
modelSummarySource.includes("当地时间") &&
|
||||||
|
|||||||
@@ -29,14 +29,6 @@ export type ModelSummaryProbabilityBucket = {
|
|||||||
probability: number;
|
probability: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModelSummaryProbabilityColumn = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
lower: number;
|
|
||||||
upper: number;
|
|
||||||
unit: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ModelSummaryRow = {
|
export type ModelSummaryRow = {
|
||||||
cityKey: string;
|
cityKey: string;
|
||||||
cityName: string;
|
cityName: string;
|
||||||
@@ -324,27 +316,3 @@ export function hasModelSummaryForecastData(rows: ModelSummaryRow[]) {
|
|||||||
return MODEL_SUMMARY_MODEL_COLUMNS.some((column) => row.models[column.key] != null);
|
return MODEL_SUMMARY_MODEL_COLUMNS.some((column) => row.models[column.key] != null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildModelSummaryProbabilityColumns(
|
|
||||||
rows: ModelSummaryRow[],
|
|
||||||
): ModelSummaryProbabilityColumn[] {
|
|
||||||
const byKey = new Map<string, ModelSummaryProbabilityColumn>();
|
|
||||||
rows.forEach((row) => {
|
|
||||||
row.probabilityBuckets.forEach((bucket) => {
|
|
||||||
if (byKey.has(bucket.key)) return;
|
|
||||||
const unitMatch = bucket.key.match(/[^\d.\-\s]+$/);
|
|
||||||
byKey.set(bucket.key, {
|
|
||||||
key: bucket.key,
|
|
||||||
label: bucket.label,
|
|
||||||
lower: bucket.lower,
|
|
||||||
upper: bucket.upper,
|
|
||||||
unit: unitMatch?.[0] || row.tempSymbol || "°C",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...byKey.values()].sort((a, b) => {
|
|
||||||
if (a.unit !== b.unit) return a.unit.localeCompare(b.unit);
|
|
||||||
return a.lower - b.lower || a.upper - b.upper;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user