Group model stack and dedupe DEB families

This commit is contained in:
2569718930@qq.com
2026-04-17 00:35:48 +08:00
parent 7909a16002
commit 9ec86e0504
5 changed files with 461 additions and 38 deletions
@@ -1005,7 +1005,7 @@
.root :global(.model-bars) {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
}
.root :global(.model-row) {
@@ -1026,6 +1026,91 @@
text-overflow: ellipsis;
}
.root :global(.model-row-rich) {
align-items: flex-start;
}
.root :global(.model-row-rich .model-name) {
width: 118px;
white-space: normal;
line-height: 1.25;
}
.root :global(.model-row-rich .model-name strong) {
display: block;
color: var(--text-primary);
font-size: 12px;
}
.root :global(.model-row-rich .model-name span) {
display: block;
margin-top: 2px;
color: var(--text-muted);
font-size: 10px;
font-weight: 500;
}
.root :global(.model-stack-summary) {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 2px;
}
.root :global(.model-stack-summary span) {
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 6px;
background: rgba(15, 23, 42, 0.5);
color: var(--text-secondary);
font-size: 11px;
padding: 5px 8px;
}
.root :global(.model-stack-summary strong) {
color: var(--text-primary);
font-weight: 800;
}
.root :global(.model-group) {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border-left: 2px solid rgba(148, 163, 184, 0.26);
background: rgba(15, 23, 42, 0.28);
border-radius: 6px;
}
.root :global(.model-group-cyan) {
border-left-color: rgba(34, 211, 238, 0.75);
}
.root :global(.model-group-blue) {
border-left-color: rgba(96, 165, 250, 0.75);
}
.root :global(.model-group-amber) {
border-left-color: rgba(245, 158, 11, 0.8);
}
.root :global(.model-group-heading) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: var(--accent-cyan);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.root :global(.model-group-heading em) {
color: var(--text-muted);
font-style: normal;
letter-spacing: 0;
}
.root :global(.model-bar-track) {
flex: 1;
height: 20px;
@@ -1038,7 +1123,7 @@
.root :global(.model-bar-fill) {
height: 100%;
border-radius: 6px;
background: linear-gradient(90deg, var(--accent-purple), var(--accent-blue));
background: linear-gradient(90deg, rgba(14, 165, 233, 0.72), rgba(34, 211, 238, 0.92));
transition: width 0.6s ease-out;
display: flex;
align-items: center;
+169 -29
View File
@@ -100,6 +100,80 @@ function getMarketYesPrice(scan?: MarketScan | null) {
return null;
}
type ModelMetadata = NonNullable<
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
>["model_metadata"];
function getModelGroupMeta(
name: string,
metadata: ModelMetadata,
locale: string,
) {
const meta = metadata?.[name] || {};
const tier = String(meta.tier || "").toLowerCase();
const upperName = String(name || "").toUpperCase();
if (tier.includes("ai") || upperName.includes("AIFS")) {
return {
key: "ai",
label: locale === "en-US" ? "AI forecast" : "AI 预报",
order: 1,
tone: "blue",
};
}
if (
tier.includes("europe") ||
upperName.includes("ICON-EU") ||
upperName.includes("ICON-D2")
) {
return {
key: "europe",
label: locale === "en-US" ? "Europe high-resolution" : "欧洲高分辨率",
order: 2,
tone: "cyan",
};
}
if (
tier.includes("north_america") ||
upperName === "RDPS" ||
upperName === "HRDPS"
) {
return {
key: "north-america",
label: locale === "en-US" ? "North America high-resolution" : "北美高分辨率",
order: 3,
tone: "amber",
};
}
return {
key: "global",
label: locale === "en-US" ? "Global baseline" : "全球基准",
order: 0,
tone: "neutral",
};
}
function formatModelMetaLine(
name: string,
metadata: ModelMetadata,
locale: string,
) {
const meta = metadata?.[name] || {};
const provider = String(meta.provider || "").trim();
const model = String(meta.model || "").trim();
const horizon = String(meta.horizon || "").trim();
const resolution = Number(meta.resolution_km);
const parts = [
provider,
model && model !== name ? model : "",
Number.isFinite(resolution)
? `${resolution}${locale === "en-US" ? " km" : " 公里"}`
: "",
horizon,
].filter(Boolean);
return parts.join(" · ");
}
function getMarketNoPrice(scan?: MarketScan | null) {
if (scan?.no_buy != null) {
const direct = Number(scan.no_buy);
@@ -619,6 +693,8 @@ export function ModelForecast({
const { locale, t } = useI18n();
const view = getModelView(detail, targetDate);
const modelsMap = { ...view.models };
const modelMetadata =
detail.source_forecasts?.open_meteo_multi_model?.model_metadata || {};
const modelEntries = Object.entries(modelsMap).filter(
([, value]) =>
@@ -648,11 +724,66 @@ export function ModelForecast({
? Math.max(...comparisonValues) + 1
: 1;
const range = Math.max(maxValue - minValue, 1);
const sortedEntries = modelEntries.sort(
(a, b) => Number(b[1] || 0) - Number(a[1] || 0),
);
const groupedEntries = sortedEntries.reduce(
(acc, [name, value]) => {
const group = getModelGroupMeta(name, modelMetadata, locale);
const existing = acc.find((item) => item.key === group.key);
const entry = {
metaLine: formatModelMetaLine(name, modelMetadata, locale),
name,
value: Number(value),
};
if (existing) {
existing.entries.push(entry);
} else {
acc.push({ ...group, entries: [entry] });
}
return acc;
},
[] as Array<{
entries: Array<{ metaLine: string; name: string; value: number }>;
key: string;
label: string;
order: number;
tone: string;
}>,
).sort((a, b) => a.order - b.order);
const spread =
numericValues.length >= 2
? Math.max(...numericValues) - Math.min(...numericValues)
: null;
const metadataSource =
detail.source_forecasts?.open_meteo_multi_model?.provider === "open-meteo"
? "Open-Meteo"
: null;
return (
<section className="models-section">
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
<div className="model-stack-summary">
<span>
{locale === "en-US" ? "Available models" : "可用模型"} ·{" "}
<strong>{modelEntries.length}</strong>
</span>
<span>
{locale === "en-US" ? "Spread" : "分歧"} ·{" "}
<strong>
{spread != null
? `${spread.toFixed(1)}${detail.temp_symbol}`
: "--"}
</strong>
</span>
{metadataSource && (
<span>
{locale === "en-US" ? "Source" : "来源"} ·{" "}
<strong>{metadataSource}</strong>
</span>
)}
</div>
{hasSingleModelOnly && (
<div
style={{
@@ -666,39 +797,48 @@ export function ModelForecast({
: "当前处于单模型回退,其他模型结果还没回传。"}
</div>
)}
{modelEntries
.sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0))
.map(([name, value]) => {
const numeric = Number(value);
const width = ((numeric - minValue) / range) * 100;
const debLine =
view.deb != null
? ((Number(view.deb) - minValue) / range) * 100
: null;
{groupedEntries.map((group) => (
<div
key={group.key}
className={clsx("model-group", `model-group-${group.tone}`)}
>
<div className="model-group-heading">
<span>{group.label}</span>
<em>{group.entries.length}</em>
</div>
{group.entries.map(({ metaLine, name, value }) => {
const width = ((value - minValue) / range) * 100;
const debLine =
view.deb != null
? ((Number(view.deb) - minValue) / range) * 100
: null;
return (
<div key={name} className="model-row">
<div className="model-name" title={name}>
{name}
</div>
<div className="model-bar-track">
<div
className="model-bar-fill"
style={{ width: `${width}%` }}
>
{numeric}
{detail.temp_symbol}
return (
<div key={name} className="model-row model-row-rich">
<div className="model-name" title={metaLine || name}>
<strong>{name}</strong>
{metaLine && <span>{metaLine}</span>}
</div>
{debLine != null && (
<div className="model-bar-track">
<div
className="model-deb-line"
style={{ left: `${debLine}%` }}
/>
)}
className="model-bar-fill"
style={{ width: `${width}%` }}
>
{value}
{detail.temp_symbol}
</div>
{debLine != null && (
<div
className="model-deb-line"
style={{ left: `${debLine}%` }}
/>
)}
</div>
</div>
</div>
);
})}
);
})}
</div>
))}
{view.deb != null && (
<div
className="model-row"
+19
View File
@@ -238,6 +238,25 @@ export interface SourceForecasts {
weather_gov?: {
forecast_periods?: WeatherGovPeriod[];
};
open_meteo_multi_model?: {
source?: string | null;
provider?: string | null;
dates?: string[];
model_metadata?: Record<
string,
{
label?: string | null;
provider?: string | null;
model?: string | null;
tier?: string | null;
resolution_km?: number | null;
horizon?: string | null;
open_meteo_model?: string | null;
}
>;
model_keys?: Record<string, string>;
attribution?: string | null;
};
}
export interface DailyModelForecast {