Add model summary terminal view
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Table2,
|
||||
UserRound,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@@ -48,6 +49,7 @@ import { scanRootClass } from "@/components/dashboard/scan-root-styles";
|
||||
import { useRelativeTime } from "@/hooks/useRelativeTime";
|
||||
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
||||
import { UsageGuideDashboard } from "@/components/dashboard/scan-terminal/UsageGuideDashboard";
|
||||
import { ModelSummaryDashboard } from "@/components/dashboard/scan-terminal/ModelSummaryDashboard";
|
||||
import {
|
||||
LiveTemperatureThresholdChart,
|
||||
clearCityDetailCache,
|
||||
@@ -99,6 +101,7 @@ const TrainingDashboard = dynamic(
|
||||
const ONLINE_USERS_REFRESH_MS = 5 * 60_000;
|
||||
const TERMINAL_NAV_ITEMS = [
|
||||
{ key: "thresholds", Icon: Activity, labelEn: "Decision", labelZh: "天气决策" },
|
||||
{ key: "modelSummary", Icon: Table2, labelEn: "Model Summary", labelZh: "模型汇总" },
|
||||
{ key: "training", Icon: GraduationCap, labelEn: "Training", labelZh: "训练数据" },
|
||||
{ key: "guide", Icon: BookOpenCheck, labelEn: "Guide", labelZh: "使用指南" },
|
||||
] as const;
|
||||
@@ -1122,6 +1125,8 @@ function PolyWeatherTerminal({
|
||||
<main className="min-h-0 flex-1 overflow-hidden flex flex-col p-2 bg-[#eef2f6]">
|
||||
{activeNavKey === "training" ? (
|
||||
<TrainingDashboard isEn={isEn} />
|
||||
) : activeNavKey === "modelSummary" ? (
|
||||
<ModelSummaryDashboard rows={rows} isEn={isEn} generatedText={generatedText} />
|
||||
) : activeNavKey === "guide" ? (
|
||||
<UsageGuideDashboard isEn={isEn} />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { Search, Table2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import {
|
||||
MODEL_SUMMARY_MODEL_COLUMNS,
|
||||
buildModelSummaryRows,
|
||||
filterModelSummaryRows,
|
||||
formatModelSummaryTemp,
|
||||
type ModelSummaryRow,
|
||||
} from "@/lib/model-summary";
|
||||
|
||||
type ModelSummaryDashboardProps = {
|
||||
rows: ScanOpportunityRow[];
|
||||
isEn: boolean;
|
||||
generatedText?: string;
|
||||
};
|
||||
|
||||
const SUMMARY_TEXT = {
|
||||
title: { en: "Model Summary", zh: "模型汇总" },
|
||||
subtitle: {
|
||||
en: "Today high-temperature view across DEB and model cluster sources.",
|
||||
zh: "按今日最高温口径汇总 DEB 与多模型预测。",
|
||||
},
|
||||
search: { en: "Search city or model", zh: "搜索城市或模型" },
|
||||
city: { en: "City", zh: "城市" },
|
||||
region: { en: "Region", zh: "区域" },
|
||||
currentHigh: { en: "Current High", zh: "当前最高" },
|
||||
median: { en: "Median", zh: "模型中位数" },
|
||||
spread: { en: "Spread", zh: "分歧范围" },
|
||||
updated: { en: "Updated", zh: "更新时间" },
|
||||
empty: { en: "No model summary rows match the current filters.", zh: "当前筛选下没有模型汇总数据。" },
|
||||
generated: { en: "Generated", zh: "生成" },
|
||||
total: { en: "rows", zh: "行" },
|
||||
} as const;
|
||||
|
||||
function copy(key: keyof typeof SUMMARY_TEXT, isEn: boolean) {
|
||||
return SUMMARY_TEXT[key][isEn ? "en" : "zh"];
|
||||
}
|
||||
|
||||
function TemperatureCell({
|
||||
value,
|
||||
symbol,
|
||||
emphasis,
|
||||
}: {
|
||||
value: number | null;
|
||||
symbol: string;
|
||||
emphasis?: "deb" | "median";
|
||||
}) {
|
||||
const missing = value == null;
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"font-mono tabular-nums",
|
||||
missing ? "font-semibold text-slate-300" : "font-bold text-slate-800",
|
||||
emphasis === "deb" && !missing && "text-orange-600",
|
||||
emphasis === "median" && !missing && "text-blue-700",
|
||||
)}
|
||||
>
|
||||
{formatModelSummaryTemp(value, symbol)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterToggle({
|
||||
checked,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"inline-flex h-8 cursor-pointer select-none items-center gap-2 rounded border px-2.5 text-[11px] font-bold transition-colors",
|
||||
checked
|
||||
? "border-blue-300 bg-blue-50 text-blue-700"
|
||||
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50 hover:text-slate-900",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 accent-blue-600"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelSummaryRowView({
|
||||
row,
|
||||
}: {
|
||||
row: ModelSummaryRow;
|
||||
}) {
|
||||
return (
|
||||
<tr className="group border-b border-slate-100 hover:bg-blue-50/40">
|
||||
<th
|
||||
scope="row"
|
||||
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"
|
||||
>
|
||||
<span className="block truncate">{row.cityName}</span>
|
||||
</th>
|
||||
<td className="min-w-[150px] px-3 py-2 text-xs font-semibold text-slate-600">
|
||||
<span className="block truncate">{row.regionLabel}</span>
|
||||
</td>
|
||||
<td className="min-w-[96px] px-3 py-2 text-right">
|
||||
<TemperatureCell value={row.currentHigh} symbol={row.tempSymbol} />
|
||||
</td>
|
||||
<td className="min-w-[90px] px-3 py-2 text-right">
|
||||
<TemperatureCell value={row.debPrediction} symbol={row.tempSymbol} emphasis="deb" />
|
||||
</td>
|
||||
{MODEL_SUMMARY_MODEL_COLUMNS.map((column) => (
|
||||
<td key={column.key} className="min-w-[92px] px-3 py-2 text-right">
|
||||
<TemperatureCell value={row.models[column.key]} symbol={row.tempSymbol} />
|
||||
</td>
|
||||
))}
|
||||
<td className="min-w-[110px] px-3 py-2 text-right">
|
||||
<TemperatureCell value={row.modelMedian} symbol={row.tempSymbol} emphasis="median" />
|
||||
</td>
|
||||
<td className="min-w-[100px] px-3 py-2 text-right">
|
||||
<TemperatureCell value={row.modelSpread} symbol={row.tempSymbol} />
|
||||
</td>
|
||||
<td className="min-w-[130px] px-3 py-2 text-right font-mono text-[11px] font-semibold text-slate-500">
|
||||
{row.updatedAt || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelSummaryDashboard({
|
||||
rows,
|
||||
isEn,
|
||||
generatedText,
|
||||
}: ModelSummaryDashboardProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [debOnly, setDebOnly] = useState(false);
|
||||
const [wideSpreadOnly, setWideSpreadOnly] = useState(false);
|
||||
|
||||
const summaryRows = useMemo(() => buildModelSummaryRows(rows, isEn), [rows, isEn]);
|
||||
const visibleRows = useMemo(
|
||||
() =>
|
||||
filterModelSummaryRows(summaryRows, {
|
||||
query,
|
||||
debOnly,
|
||||
wideSpreadOnly,
|
||||
}),
|
||||
[summaryRows, query, debOnly, wideSpreadOnly],
|
||||
);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Table2 size={16} className="text-blue-600" />
|
||||
<h2 className="truncate text-sm font-black text-slate-900">{copy("title", isEn)}</h2>
|
||||
<span className="rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[10px] font-bold text-slate-500">
|
||||
{visibleRows.length}/{summaryRows.length} {copy("total", isEn)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-medium text-slate-500">
|
||||
{copy("subtitle", isEn)}
|
||||
{generatedText ? (
|
||||
<span className="ml-2 font-mono text-[11px] text-slate-400">
|
||||
{copy("generated", isEn)} {generatedText}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative w-full sm:w-[240px]">
|
||||
<Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={copy("search", isEn)}
|
||||
className="h-8 w-full rounded border border-slate-300 bg-white pl-8 pr-2 text-xs font-semibold text-slate-700 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
<FilterToggle
|
||||
checked={debOnly}
|
||||
label={isEn ? "Only DEB" : "仅 DEB"}
|
||||
onChange={setDebOnly}
|
||||
/>
|
||||
<FilterToggle
|
||||
checked={wideSpreadOnly}
|
||||
label={isEn ? "Large spread" : "分歧较大"}
|
||||
onChange={setWideSpreadOnly}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="w-full min-w-[1580px] border-collapse text-xs">
|
||||
<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>
|
||||
<th className="sticky left-0 z-30 w-[160px] min-w-[160px] border-r border-slate-200 bg-slate-50 px-3 py-2 text-left">
|
||||
{copy("city", isEn)}
|
||||
</th>
|
||||
<th className="min-w-[150px] px-3 py-2 text-left">{copy("region", isEn)}</th>
|
||||
<th className="min-w-[96px] px-3 py-2 text-right">{copy("currentHigh", isEn)}</th>
|
||||
<th className="min-w-[90px] px-3 py-2 text-right text-orange-600">DEB</th>
|
||||
{MODEL_SUMMARY_MODEL_COLUMNS.map((column) => (
|
||||
<th key={column.key} className="min-w-[92px] px-3 py-2 text-right">
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="min-w-[110px] px-3 py-2 text-right text-blue-700">
|
||||
{copy("median", isEn)}
|
||||
</th>
|
||||
<th className="min-w-[100px] px-3 py-2 text-right">{copy("spread", isEn)}</th>
|
||||
<th className="min-w-[130px] px-3 py-2 text-right">{copy("updated", isEn)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 bg-white">
|
||||
{visibleRows.map((row) => (
|
||||
<ModelSummaryRowView key={row.cityKey} row={row} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!visibleRows.length && (
|
||||
<div className="flex h-40 items-center justify-center border-t border-slate-100 text-xs font-bold text-slate-400">
|
||||
{copy("empty", isEn)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
MODEL_SUMMARY_MODEL_COLUMNS,
|
||||
buildModelSummaryRows,
|
||||
filterModelSummaryRows,
|
||||
formatModelSummaryTemp,
|
||||
} from "@/lib/model-summary";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const rows = [
|
||||
{
|
||||
city: "paris",
|
||||
city_display_name: "Paris",
|
||||
trading_region_label: "Europe / Africa",
|
||||
trading_region_label_zh: "欧洲 / 非洲",
|
||||
trading_region_sort: 5,
|
||||
temp_symbol: "°C",
|
||||
current_max_so_far: 32,
|
||||
deb_prediction: 31.6,
|
||||
local_time: "2026-06-29T10:00",
|
||||
model_cluster_sources: {
|
||||
ECMWF: 32.2,
|
||||
"ECMWF AIFS": 31.9,
|
||||
GFS: 33.4,
|
||||
ICON: 31.2,
|
||||
"ICON-EU": 31.4,
|
||||
GEM: 32.8,
|
||||
GDPS: 32.5,
|
||||
JMA: 30.9,
|
||||
"AROME HD": 32.1,
|
||||
},
|
||||
},
|
||||
{
|
||||
city: "madrid",
|
||||
city_display_name: "Madrid",
|
||||
trading_region_label: "Europe / Africa",
|
||||
trading_region_label_zh: "欧洲 / 非洲",
|
||||
trading_region_sort: 5,
|
||||
temp_symbol: "°C",
|
||||
current_max_so_far: 34.2,
|
||||
deb_prediction: null,
|
||||
local_time: "2026-06-29T10:05",
|
||||
model_cluster_sources: {
|
||||
ECMWF: 37,
|
||||
GFS: 38.2,
|
||||
},
|
||||
},
|
||||
] as any;
|
||||
const originalFirstModelSources = rows[0].model_cluster_sources;
|
||||
const summaryRows = buildModelSummaryRows(rows, false);
|
||||
|
||||
assert(
|
||||
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("AROME HD") &&
|
||||
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("HRRR") &&
|
||||
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("NAM"),
|
||||
"model summary must expose the fixed model columns including optional short-range models",
|
||||
);
|
||||
assert(summaryRows.length === 2, "model summary should keep one row per city");
|
||||
assert(summaryRows[0].cityName === "Madrid", "model summary should sort by region then city name");
|
||||
assert(summaryRows[1].cityName === "Paris", "model summary should sort by region then city name");
|
||||
assert(summaryRows[1].debPrediction === 31.6, "model summary should preserve DEB prediction");
|
||||
assert(summaryRows[1].models.GFS === 33.4, "model summary should preserve model high temperature");
|
||||
assert(summaryRows[1].models.HRRR === null, "missing models should be normalized to null");
|
||||
assert(summaryRows[1].modelMedian === 32.1, "model median should use available model values only");
|
||||
assert(summaryRows[1].modelSpread === 2.5, "model spread should use available model min/max only");
|
||||
assert(formatModelSummaryTemp(null, "°C") === "—", "missing model temperatures should render as an em dash");
|
||||
assert(formatModelSummaryTemp(32.16, "°C") === "32.2°C", "model temperatures should render to one decimal");
|
||||
|
||||
const searched = filterModelSummaryRows(summaryRows, {
|
||||
debOnly: true,
|
||||
query: "par",
|
||||
wideSpreadOnly: false,
|
||||
});
|
||||
assert(searched.length === 1 && searched[0].cityName === "Paris", "model summary search and DEB filter should compose");
|
||||
const wideSpread = filterModelSummaryRows(summaryRows, {
|
||||
debOnly: false,
|
||||
query: "",
|
||||
wideSpreadOnly: true,
|
||||
});
|
||||
assert(
|
||||
wideSpread.length === 1 && wideSpread[0].cityName === "Paris",
|
||||
"wide-spread filter should only keep rows with model spread >= 2°C",
|
||||
);
|
||||
assert(rows[0].model_cluster_sources === originalFirstModelSources, "model summary filters must not mutate source rows");
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const dashboardSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const modelSummarySource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "ModelSummaryDashboard.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("modelSummary") &&
|
||||
dashboardSource.includes("模型汇总") &&
|
||||
dashboardSource.includes("Model Summary") &&
|
||||
dashboardSource.includes("Table2"),
|
||||
"terminal sidebar must expose the model summary nav item",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("<ModelSummaryDashboard") &&
|
||||
dashboardSource.includes("rows={rows}") &&
|
||||
dashboardSource.includes("generatedText={generatedText}"),
|
||||
"terminal model summary view must use existing scan rows instead of fetching city detail",
|
||||
);
|
||||
assert(
|
||||
modelSummarySource.includes("MODEL_SUMMARY_MODEL_COLUMNS") &&
|
||||
modelSummarySource.includes("Only DEB") &&
|
||||
modelSummarySource.includes("仅 DEB") &&
|
||||
modelSummarySource.includes("Large spread") &&
|
||||
modelSummarySource.includes("分歧较大"),
|
||||
"model summary dashboard must render the fixed model table and filters",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
|
||||
export const MODEL_SUMMARY_MODEL_COLUMNS = [
|
||||
{ key: "ECMWF", label: "ECMWF" },
|
||||
{ key: "ECMWF AIFS", label: "ECMWF AIFS" },
|
||||
{ key: "GFS", label: "GFS" },
|
||||
{ key: "ICON", label: "ICON" },
|
||||
{ key: "ICON-EU", label: "ICON-EU" },
|
||||
{ key: "GEM", label: "GEM" },
|
||||
{ key: "GDPS", label: "GDPS" },
|
||||
{ key: "JMA", label: "JMA" },
|
||||
{ key: "AROME HD", label: "AROME HD" },
|
||||
{ key: "HRRR", label: "HRRR" },
|
||||
{ key: "NAM", label: "NAM" },
|
||||
] as const;
|
||||
|
||||
export type ModelSummaryColumnKey = (typeof MODEL_SUMMARY_MODEL_COLUMNS)[number]["key"];
|
||||
|
||||
export type ModelSummaryRow = {
|
||||
cityKey: string;
|
||||
cityName: string;
|
||||
regionLabel: string;
|
||||
regionLabelZh: string;
|
||||
regionSort: number;
|
||||
tempSymbol: string;
|
||||
currentHigh: number | null;
|
||||
debPrediction: number | null;
|
||||
models: Record<ModelSummaryColumnKey, number | null>;
|
||||
modelMedian: number | null;
|
||||
modelSpread: number | null;
|
||||
updatedAt: string;
|
||||
searchText: string;
|
||||
};
|
||||
|
||||
export type ModelSummaryFilters = {
|
||||
query: string;
|
||||
debOnly: boolean;
|
||||
wideSpreadOnly: boolean;
|
||||
};
|
||||
|
||||
const WIDE_SPREAD_THRESHOLD = 2;
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
if (value == null) return null;
|
||||
if (typeof value === "string" && value.trim() === "") return null;
|
||||
const numericValue = Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : null;
|
||||
}
|
||||
|
||||
function roundToOneDecimal(value: number) {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function median(values: number[]) {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 1) return roundToOneDecimal(sorted[mid]);
|
||||
return roundToOneDecimal((sorted[mid - 1] + sorted[mid]) / 2);
|
||||
}
|
||||
|
||||
function spread(values: number[]) {
|
||||
if (!values.length) return null;
|
||||
return roundToOneDecimal(Math.max(...values) - Math.min(...values));
|
||||
}
|
||||
|
||||
function normalizeCityKey(row: ScanOpportunityRow, index: number) {
|
||||
const rawKey = row.city || row.city_display_name || row.display_name || `row-${index}`;
|
||||
return String(rawKey).trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function formatModelSummaryTemp(value: number | null | undefined, symbol = "°C") {
|
||||
const numericValue = finiteNumber(value);
|
||||
if (numericValue == null) return "—";
|
||||
return `${numericValue.toFixed(1)}${symbol || "°C"}`;
|
||||
}
|
||||
|
||||
export function buildModelSummaryRows(
|
||||
rows: ScanOpportunityRow[],
|
||||
isEn: boolean,
|
||||
): ModelSummaryRow[] {
|
||||
const byCity = new Map<string, ModelSummaryRow>();
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const cityKey = normalizeCityKey(row, index);
|
||||
if (byCity.has(cityKey)) return;
|
||||
|
||||
const cityName = row.city_display_name || row.display_name || row.city || "—";
|
||||
const regionLabel = row.trading_region_label || row.trading_region_label_zh || "—";
|
||||
const regionLabelZh = row.trading_region_label_zh || row.trading_region_label || "—";
|
||||
const displayRegionLabel = isEn ? regionLabel : regionLabelZh;
|
||||
const rawModelSources = row.model_cluster_sources || {};
|
||||
const models = MODEL_SUMMARY_MODEL_COLUMNS.reduce(
|
||||
(acc, column) => {
|
||||
acc[column.key] = finiteNumber(rawModelSources[column.key]);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<ModelSummaryColumnKey, number | null>,
|
||||
);
|
||||
const modelValues = MODEL_SUMMARY_MODEL_COLUMNS.map((column) => models[column.key]).filter(
|
||||
(value): value is number => value != null,
|
||||
);
|
||||
const modelSearchText = MODEL_SUMMARY_MODEL_COLUMNS.filter(
|
||||
(column) => models[column.key] != null,
|
||||
)
|
||||
.map((column) => column.label)
|
||||
.join(" ");
|
||||
|
||||
byCity.set(cityKey, {
|
||||
cityKey,
|
||||
cityName,
|
||||
regionLabel: displayRegionLabel,
|
||||
regionLabelZh,
|
||||
regionSort: finiteNumber(row.trading_region_sort) ?? 999,
|
||||
tempSymbol: row.temp_symbol || "°C",
|
||||
currentHigh: finiteNumber(row.current_max_so_far),
|
||||
debPrediction: finiteNumber(row.deb_prediction),
|
||||
models,
|
||||
modelMedian: median(modelValues),
|
||||
modelSpread: spread(modelValues),
|
||||
updatedAt: row.local_time || row.local_date || row.selected_date || "",
|
||||
searchText: `${cityName} ${row.city || ""} ${regionLabel} ${regionLabelZh} ${modelSearchText}`.toLowerCase(),
|
||||
});
|
||||
});
|
||||
|
||||
return [...byCity.values()].sort((a, b) => {
|
||||
if (a.regionSort !== b.regionSort) return a.regionSort - b.regionSort;
|
||||
return a.cityName.localeCompare(b.cityName, isEn ? "en" : "zh-CN", {
|
||||
sensitivity: "base",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function filterModelSummaryRows(
|
||||
rows: ModelSummaryRow[],
|
||||
filters: ModelSummaryFilters,
|
||||
): ModelSummaryRow[] {
|
||||
const query = filters.query.trim().toLowerCase();
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (query && !row.searchText.includes(query)) return false;
|
||||
if (filters.debOnly && row.debPrediction == null) return false;
|
||||
if (
|
||||
filters.wideSpreadOnly &&
|
||||
(row.modelSpread == null || row.modelSpread < WIDE_SPREAD_THRESHOLD)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user