"use client";
import clsx from "clsx";
import { ChevronRight, Search, Table2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import {
MODEL_SUMMARY_MODEL_COLUMNS,
buildModelSummaryRows,
filterModelSummaryRows,
formatModelSummaryProbability,
formatModelSummaryLocalTime,
formatModelSummaryTemp,
hasModelSummaryForecastData,
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: "区域" },
localTime: { en: "Local Time", zh: "当地时间" },
gaussianMu: { en: "Gaussian μ", zh: "高斯 μ" },
detailedProbability: { en: "Gaussian Distribution Probability", zh: "高斯分布概率" },
noProbabilityDistribution: { en: "No probability distribution", zh: "暂无详细概率" },
median: { en: "Median", zh: "模型中位数" },
spread: { en: "Spread", zh: "分歧范围" },
empty: { en: "No model summary rows match the current filters.", zh: "当前筛选下没有模型汇总数据。" },
generated: { en: "Generated", zh: "生成" },
total: { en: "rows", zh: "行" },
} as const;
const MODEL_SUMMARY_COLUMN_COUNT = MODEL_SUMMARY_MODEL_COLUMNS.length + 7;
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,
nowMs,
isEn,
expanded,
onToggle,
}: {
row: ModelSummaryRow;
nowMs: number | null;
isEn: boolean;
expanded: boolean;
onToggle: () => void;
}) {
return (
<>
|
|
{row.regionLabel}
|
{formatModelSummaryLocalTime(row, nowMs)}
|
|
{MODEL_SUMMARY_MODEL_COLUMNS.map((column) => (
|
))}
|
|
|
{expanded ? (
{copy("detailedProbability", isEn)}
μ {formatModelSummaryTemp(row.gaussianMu, row.tempSymbol)}
{row.probabilityBuckets.length ? (
{row.probabilityBuckets.map((bucket) => {
const isTopBucket = bucket.key === row.topProbabilityBucketKey;
return (
{bucket.label}
{formatModelSummaryProbability(bucket.probability)}
);
})}
) : (
{copy("noProbabilityDistribution", isEn)}
)}
|
) : null}
>
);
}
function mergeWithLastGoodSummaryRows(
incomingRows: ModelSummaryRow[],
lastGoodRows: ModelSummaryRow[],
) {
if (!lastGoodRows.length) return incomingRows;
if (!incomingRows.length) return lastGoodRows;
const incomingCityKeys = new Set();
const lastGoodByCity = new Map(lastGoodRows.map((row) => [row.cityKey, row]));
const mergedRows = incomingRows.map((incomingRow) => {
incomingCityKeys.add(incomingRow.cityKey);
const lastGoodRow = lastGoodByCity.get(incomingRow.cityKey);
if (!lastGoodRow) return incomingRow;
return {
...lastGoodRow,
cityName: incomingRow.cityName,
regionLabel: incomingRow.regionLabel,
regionLabelZh: incomingRow.regionLabelZh,
regionSort: incomingRow.regionSort,
tempSymbol: incomingRow.tempSymbol || lastGoodRow.tempSymbol,
localTime: incomingRow.localTime || lastGoodRow.localTime,
timezoneOffsetSeconds:
incomingRow.timezoneOffsetSeconds ?? lastGoodRow.timezoneOffsetSeconds,
searchText: incomingRow.searchText || lastGoodRow.searchText,
};
});
return [
...mergedRows,
...lastGoodRows.filter((row) => !incomingCityKeys.has(row.cityKey)),
].sort((a, b) => {
if (a.regionSort !== b.regionSort) return a.regionSort - b.regionSort;
return a.cityName.localeCompare(b.cityName, "en", { sensitivity: "base" });
});
}
export function ModelSummaryDashboard({
rows,
isEn,
generatedText,
}: ModelSummaryDashboardProps) {
const [query, setQuery] = useState("");
const [debOnly, setDebOnly] = useState(false);
const [wideSpreadOnly, setWideSpreadOnly] = useState(false);
const [nowMs, setNowMs] = useState(null);
const [expandedCityKeys, setExpandedCityKeys] = useState>(() => new Set());
const lastGoodSummaryRowsRef = useRef([]);
useEffect(() => {
const syncClock = () => setNowMs(Date.now());
syncClock();
const timer = window.setInterval(syncClock, 30_000);
return () => window.clearInterval(timer);
}, []);
const incomingSummaryRows = useMemo(() => buildModelSummaryRows(rows, isEn), [rows, isEn]);
const incomingHasForecastData = hasModelSummaryForecastData(incomingSummaryRows);
const summaryRows = useMemo(
() =>
incomingHasForecastData
? incomingSummaryRows
: mergeWithLastGoodSummaryRows(incomingSummaryRows, lastGoodSummaryRowsRef.current),
[incomingSummaryRows, incomingHasForecastData],
);
useEffect(() => {
if (incomingHasForecastData) {
lastGoodSummaryRowsRef.current = incomingSummaryRows;
}
}, [incomingSummaryRows, incomingHasForecastData]);
const visibleRows = useMemo(
() =>
filterModelSummaryRows(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();
current.forEach((cityKey) => {
if (visibleCityKeys.has(cityKey)) {
next.add(cityKey);
} else {
changed = true;
}
});
return changed ? next : current;
});
}, [visibleRows]);
return (
{copy("title", isEn)}
{visibleRows.length}/{summaryRows.length} {copy("total", isEn)}
{copy("subtitle", isEn)}
{generatedText ? (
{copy("generated", isEn)} {generatedText}
) : null}
|
{copy("city", isEn)}
|
{copy("region", isEn)} |
{copy("localTime", isEn)} |
DEB |
{MODEL_SUMMARY_MODEL_COLUMNS.map((column) => (
{column.label}
|
))}
{copy("median", isEn)}
|
{copy("spread", isEn)} |
{copy("gaussianMu", isEn)}
|
{visibleRows.map((row) => (
toggleExpandedCity(row.cityKey)}
/>
))}
{!visibleRows.length && (
{copy("empty", isEn)}
)}
);
}