From a680fca73ea09cc1c6b97efd576b209204ad74cf Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 29 Jun 2026 19:04:15 +0800 Subject: [PATCH] Add model summary terminal view --- .../dashboard/ScanTerminalDashboard.tsx | 5 + .../scan-terminal/ModelSummaryDashboard.tsx | 236 ++++++++++++++++++ .../__tests__/modelSummaryDashboard.test.ts | 121 +++++++++ frontend/lib/model-summary.ts | 151 +++++++++++ 4 files changed, 513 insertions(+) create mode 100644 frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx create mode 100644 frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts create mode 100644 frontend/lib/model-summary.ts diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 45ea84c3..e58e12e0 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -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({
{activeNavKey === "training" ? ( + ) : activeNavKey === "modelSummary" ? ( + ) : activeNavKey === "guide" ? ( ) : ( diff --git a/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx b/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx new file mode 100644 index 00000000..bc210246 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx @@ -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 ( + + {formatModelSummaryTemp(value, symbol)} + + ); +} + +function FilterToggle({ + checked, + label, + onChange, +}: { + checked: boolean; + label: string; + onChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +function ModelSummaryRowView({ + row, +}: { + row: ModelSummaryRow; +}) { + return ( + + + {row.cityName} + + + {row.regionLabel} + + + + + + + + {MODEL_SUMMARY_MODEL_COLUMNS.map((column) => ( + + + + ))} + + + + + + + + {row.updatedAt || "—"} + + + ); +} + +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 ( +
+
+
+
+ +

{copy("title", isEn)}

+ + {visibleRows.length}/{summaryRows.length} {copy("total", isEn)} + +
+

+ {copy("subtitle", isEn)} + {generatedText ? ( + + {copy("generated", isEn)} {generatedText} + + ) : null} +

+
+ +
+
+ + 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" + /> +
+ + +
+
+ +
+ + + + + + + + {MODEL_SUMMARY_MODEL_COLUMNS.map((column) => ( + + ))} + + + + + + + {visibleRows.map((row) => ( + + ))} + +
+ {copy("city", isEn)} + {copy("region", isEn)}{copy("currentHigh", isEn)}DEB + {column.label} + + {copy("median", isEn)} + {copy("spread", isEn)}{copy("updated", isEn)}
+ {!visibleRows.length && ( +
+ {copy("empty", isEn)} +
+ )} +
+
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts new file mode 100644 index 00000000..e130143b --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts @@ -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("; + 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(); + + 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, + ); + 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; + }); +}