Group model stack and dedupe DEB families
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user