清理旧 dashboard 组件和未使用的模块

This commit is contained in:
2569718930@qq.com
2026-05-25 05:27:16 +08:00
parent a4529e0409
commit d808d89fdd
14 changed files with 2 additions and 2327 deletions
@@ -1,416 +0,0 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardSelection } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getTemperatureChartData } from "@/lib/chart-utils";
import { getFutureModalView } from "@/lib/dashboard-utils";
import { formatMinuteAxisLabel } from "./FutureForecastModal.utils";
export function DailyTemperatureChart({
dateStr,
forceToday = false,
}: {
dateStr: string;
forceToday?: boolean;
}) {
const { selectedDetail: detail } = useDashboardSelection();
const { locale, t } = useI18n();
const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
const isToday =
forceToday || (detail ? dateStr === detail.local_date : false);
const todayChartData = useMemo(
() => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
[detail, isToday, locale],
);
const canvasRef = useChart(() => {
if (!detail || !view) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
if (isToday && todayChartData) {
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [];
datasets.push({
borderColor: "rgba(100, 116, 139, 0.72)",
borderDash: [6, 4],
borderWidth: 1.6,
data: todayChartData.datasets.debSeries,
fill: false,
label: locale === "en-US" ? "DEB baseline" : "DEB 原始路径",
parsing: false,
pointRadius: 0,
tension: 0.1,
});
if (todayChartData.datasets.calibratedFutureSeries.length > 0) {
datasets.push({
borderColor: "#38bdf8",
borderWidth: 2.4,
data: todayChartData.datasets.calibratedFutureSeries,
fill: false,
label:
locale === "en-US"
? "METAR-calibrated path"
: "METAR 修正路径",
parsing: false,
pointHoverRadius: 5,
pointRadius: 0,
tension: 0.12,
});
}
if (todayChartData.datasets.hasMgmHourly) {
datasets.push({
backgroundColor: "rgba(234, 179, 8, 0.05)",
borderColor: "rgba(234, 179, 8, 0.8)",
borderDash: [3, 3],
borderWidth: 1.2,
data: todayChartData.datasets.mgmHourlySeries,
fill: false,
label: locale === "en-US" ? "MGM Forecast" : "MGM 预测",
parsing: false,
pointHoverRadius: 6,
pointRadius: 0,
spanGaps: true,
tension: 0.1,
});
}
datasets.push({
backgroundColor: "#22c55e",
borderColor: "#22c55e",
borderWidth: 0,
data: todayChartData.datasets.metarSeries,
fill: false,
label:
todayChartData.observationLabel ||
(locale === "en-US" ? "Observation" : "观测实况"),
order: 0,
parsing: false,
pointHoverRadius: 7,
pointRadius: 5,
showLine: false,
});
if (todayChartData.datasets.airportMetarSeries?.length > 0) {
datasets.push({
backgroundColor: "#86efac",
borderColor: "#86efac",
borderWidth: 1,
data: todayChartData.datasets.airportMetarSeries,
fill: false,
label: locale === "en-US" ? "Airport METAR" : "机场 METAR",
order: 0,
parsing: false,
pointHoverRadius: 6,
pointRadius: 4,
showLine: false,
});
}
if (todayChartData.datasets.mgmSeries?.length > 0) {
datasets.push({
backgroundColor: "#facc15",
borderColor: "#facc15",
borderWidth: 0,
data: todayChartData.datasets.mgmSeries,
fill: false,
label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
order: -1,
parsing: false,
pointHoverRadius: 9,
pointRadius: 7,
showLine: false,
});
}
if (
!todayChartData.datasets.hasMgmHourly &&
Math.abs(todayChartData.datasets.offset) > 0.3
) {
datasets.push({
borderColor: "rgba(77, 163, 255, 0.22)",
borderDash: [2, 4],
borderWidth: 1,
data: todayChartData.datasets.tempsSeries,
fill: false,
label: locale === "en-US" ? "OM Raw" : "OM 原始",
parsing: false,
pointRadius: 0,
tension: 0.1,
});
}
if ((todayChartData.tafMarkers || []).length > 0) {
datasets.push({
backgroundColor: "#f59e0b",
borderColor: "#f59e0b",
borderWidth: 0,
data: todayChartData.datasets.tafCurrentMarkerSeries,
fill: false,
label: locale === "en-US" ? "Current TAF" : "当前 TAF",
order: -3,
parsing: false,
pointHoverRadius: 8,
pointRadius: 6,
showLine: false,
});
datasets.push({
backgroundColor: "rgba(250, 204, 21, 0.72)",
borderColor: "rgba(250, 204, 21, 0.72)",
borderWidth: 0,
data: todayChartData.datasets.tafPeakWindowMarkerSeries,
fill: false,
label: locale === "en-US" ? "Peak-window TAF" : "峰值窗口 TAF",
order: -2,
parsing: false,
pointHoverRadius: 7,
pointRadius: 4,
showLine: false,
});
datasets.push({
backgroundColor: "#f59e0b",
borderColor: "#f59e0b",
borderWidth: 0,
data: todayChartData.datasets.tafMarkerSeries,
fill: false,
label: locale === "en-US" ? "TAF Timing" : "TAF 时段",
order: -4,
parsing: false,
pointHoverRadius: 0,
pointRadius: 0,
showLine: false,
});
}
return {
data: {
datasets,
labels: [],
},
options: {
interaction: { intersect: false, mode: "nearest" },
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: "#9FB2C7",
filter: (legendItem, chartData) => {
const text = String(legendItem.text || "");
if (!text) return false;
if (text === "TAF Timing" || text === "TAF 时段")
return false;
if (!text.includes("DEB")) return true;
const firstDebIndex = (chartData.datasets || []).findIndex(
(dataset) => String(dataset.label || "").includes("DEB"),
);
return legendItem.datasetIndex === firstDebIndex;
},
font: { family: "Inter", size: 11 },
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.96)",
borderColor: "rgba(77, 163, 255, 0.24)",
borderWidth: 1,
callbacks: {
title: (items) => {
const rawX = items?.[0]?.parsed?.x;
return rawX != null
? formatMinuteAxisLabel(Number(rawX))
: "";
},
label: (ctx) => {
const label = String(ctx.dataset.label || "");
const raw = ctx.raw as
| {
marker?: {
summary?: string;
markerType?: string;
displayType?: string;
isCurrent?: boolean;
isPeakWindow?: boolean;
};
}
| undefined;
if (
label === "TAF Timing" ||
label === "TAF 时段" ||
label === "Current TAF" ||
label === "当前 TAF" ||
label === "Peak-window TAF" ||
label === "峰值窗口 TAF"
) {
const marker = raw?.marker;
if (!marker) return label;
const markerType = String(marker.markerType || "");
const displayType = String(
marker.displayType || marker.markerType || "",
);
const summary = String(marker.summary || "");
const prefix =
marker.isCurrent && marker.isPeakWindow
? locale === "en-US"
? "Current / peak-window TAF"
: "当前 / 峰值窗口 TAF"
: marker.isCurrent
? locale === "en-US"
? "Current TAF"
: "当前 TAF"
: marker.isPeakWindow
? locale === "en-US"
? "Peak-window TAF"
: "峰值窗口 TAF"
: label;
return `${prefix}: ${
markerType
? summary.replace(markerType, displayType)
: summary
}`;
}
const value = ctx.parsed.y;
if (value == null) return label;
return `${label}: ${value.toFixed(1)}${detail.temp_symbol || "°C"}`;
},
},
},
},
responsive: true,
scales: {
x: {
max: todayChartData.xMax,
min: todayChartData.xMin,
grid: { color: "rgba(255,255,255,0.04)" },
type: "linear",
ticks: {
callback: (value) => {
const num = Number(value);
if (!Number.isFinite(num)) return "";
const minutes = Math.round(num);
if (
minutes !== todayChartData.xMin &&
minutes !== todayChartData.xMax &&
minutes % 120 !== 0
) {
return "";
}
return formatMinuteAxisLabel(minutes);
},
color: "#6B7A90",
font: { family: "Inter", size: 10 },
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
max: todayChartData.max,
min: todayChartData.min,
ticks: {
callback: (value) =>
`${Number(value).toFixed(todayChartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
color: "#6B7A90",
font: { family: "Inter", size: 10 },
stepSize: todayChartData.yTickStep,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}
const labels = view.slice.map((point) => point.label);
const unit = detail.temp_symbol || "°C";
return {
data: {
datasets: [
{
backgroundColor: "rgba(77, 163, 255, 0.08)",
borderColor: "#4DA3FF",
data: view.slice.map((point) => point.temp),
fill: false,
label:
locale === "en-US" ? "Open-Meteo Temperature" : "Open-Meteo 温度",
pointRadius: 2,
tension: 0.1,
},
{
backgroundColor: "transparent",
borderColor: "#93C5FD",
borderDash: [5, 4],
data: view.slice.map((point) => point.dewPoint),
fill: false,
label: locale === "en-US" ? "Dew Point" : "露点",
pointRadius: 0,
tension: 0.1,
},
],
labels,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: "#9FB2C7",
font: { family: "Inter", size: 11 },
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.96)",
borderColor: "rgba(77, 163, 255, 0.24)",
borderWidth: 1,
callbacks: {
label: (ctx) =>
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${unit}`,
},
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#6B7A90",
font: { family: "Inter", size: 10 },
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
callback: (value) => `${value}${unit}`,
color: "#6B7A90",
font: { family: "Inter", size: 10 },
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [detail, isToday, locale, todayChartData, view]);
return (
<>
<div className="history-chart-wrapper future-chart-wrapper">
<canvas ref={canvasRef} />
</div>
{isToday && (
<div className="chart-legend">
{todayChartData?.legendText || t("future.chartLegendEmpty")}
</div>
)}
</>
);
}
@@ -1,63 +0,0 @@
import clsx from "clsx";
export type FutureSyncStatusItem = {
key: string;
state: "ready" | "syncing";
label: string;
note: string;
};
export function FutureRefreshLock({ locale }: { locale: string }) {
return (
<div
className="future-v2-refresh-lock"
role="status"
aria-live="assertive"
>
<span className="future-v2-refresh-spinner" aria-hidden="true" />
<div>
<strong>
{locale === "en-US"
? "Refreshing latest intraday data"
: "正在刷新最新日内数据"}
</strong>
<p>
{locale === "en-US"
? "Old cached readings are temporarily locked to prevent misjudgement. The analysis will unlock after the latest anchor observation, model layer, and probability layer are ready."
: "旧缓存读数已临时锁定,避免误判。最新锚点观测、模型层和概率层就绪后会自动解锁。"}
</p>
</div>
</div>
);
}
export function FutureSyncStatusStrip({
items,
compact = false,
}: {
items: readonly FutureSyncStatusItem[];
compact?: boolean;
}) {
return (
<section
className={clsx("future-v2-sync-strip", compact && "future-v2-sync-strip-compact")}
aria-live="polite"
>
{items.map((item) => (
<div
key={item.key}
className={clsx(
"future-v2-sync-chip",
item.state === "syncing" && "syncing",
)}
>
<span className="future-v2-sync-dot" aria-hidden="true" />
<div className="future-v2-sync-copy">
<strong>{item.label}</strong>
<span>{item.note}</span>
</div>
</div>
))}
</section>
);
}
@@ -1,26 +0,0 @@
import {
Cloud,
CloudFog,
CloudLightning,
CloudRain,
CloudSnow,
CloudSun,
Search,
Sun,
Wind,
} from "lucide-react";
export function WeatherIcon({ emoji, size = 32 }: { emoji: string; size?: number }) {
if (emoji === "☀️") return <Sun size={size} color="#facc15" />;
if (emoji === "⛅" || emoji === "🌤️")
return <CloudSun size={size} color="#4DA3FF" />;
if (emoji === "☁️") return <Cloud size={size} color="#9FB2C7" />;
if (emoji === "🌧️" || emoji === "🌦️")
return <CloudRain size={size} color="#60a5fa" />;
if (emoji === "⛈️") return <CloudLightning size={size} color="#c084fc" />;
if (emoji === "❄️" || emoji === "🌨️")
return <CloudSnow size={size} color="#7dd3fc" />;
if (emoji === "🌫️") return <CloudFog size={size} color="#a1a1aa" />;
if (emoji === "💨") return <Wind size={size} color="#cbd5e1" />;
return <Search size={size} color="#6B7A90" />;
}
-194
View File
@@ -1,194 +0,0 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import {
LogIn,
UserRound,
RotateCw,
BookOpen,
MoreHorizontal,
CloudSun,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useDashboardStore, useProAccess } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
function parseExpiryInfo(raw?: string | null) {
const text = String(raw || "").trim();
if (!text) return null;
const dt = new Date(text);
if (Number.isNaN(dt.getTime())) return null;
const diffMs = dt.getTime() - Date.now();
const daysLeft = Math.ceil(diffMs / 86_400_000);
return {
date: dt,
daysLeft,
expired: diffMs <= 0,
};
}
export function HeaderBar({
refreshAction,
refreshSpinning,
}: {
refreshAction?: () => void | Promise<void>;
refreshSpinning?: boolean;
}) {
const store = useDashboardStore();
const { proAccess } = useProAccess();
const { locale, t, toggleLocale } = useI18n();
const pathname = usePathname();
const isAuthenticated = proAccess.authenticated;
const docsHref = "/docs/intro";
const docsActive = pathname?.startsWith("/docs");
const navItems = [
{
href: "/",
label: locale === "en-US" ? "Dashboard" : "总览",
active: pathname === "/",
},
];
const isRefreshing = refreshSpinning ?? store.loadingState.refresh;
const handleRefresh = () => {
if (refreshAction) {
return void refreshAction();
}
return void store.refreshAll();
};
const accountHref = isAuthenticated
? "/account"
: "/auth/login?next=%2Faccount";
const accountAria = isAuthenticated
? t("header.accountAria")
: t("header.signInAria");
const effectiveExpiry = proAccess.subscriptionActive
? proAccess.subscriptionTotalExpiresAt ||
proAccess.subscriptionExpiresAt
: proAccess.subscriptionExpiresAt;
const expiryInfo = parseExpiryInfo(effectiveExpiry);
const hasQueuedExtension = Boolean(
proAccess.subscriptionActive &&
proAccess.subscriptionQueuedDays > 0,
);
const showRenewReminder =
isAuthenticated &&
!proAccess.loading &&
!hasQueuedExtension &&
((proAccess.subscriptionActive &&
expiryInfo &&
expiryInfo.daysLeft <= 3) ||
(!proAccess.subscriptionActive && Boolean(expiryInfo)));
const renewReminderLabel = !showRenewReminder
? ""
: !proAccess.subscriptionActive
? locale === "en-US"
? "Pro expired"
: "Pro 已到期"
: locale === "en-US"
? `Pro ${Math.max(expiryInfo?.daysLeft || 0, 0)}d left`
: `Pro 还剩 ${Math.max(expiryInfo?.daysLeft || 0, 0)}`;
return (
<header className="header">
<div className="brand">
<span className="brand-mark" aria-hidden="true">
<img src="/apple-touch-icon.png" alt="PolyWeather" />
</span>
<h1>PolyWeather</h1>
<span className="subtitle">{t("header.subtitle")}</span>
</div>
<nav
className="header-nav"
aria-label={locale === "en-US" ? "Primary" : "主导航"}
>
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={clsx("header-nav-link", item.active && "active")}
>
<span>{item.label}</span>
</Link>
))}
</nav>
<div className="header-right">
<button
type="button"
className="locale-switch"
aria-label={locale === "en-US" ? "Switch to Chinese" : "切换到英文"}
title={locale === "en-US" ? "Switch to Chinese" : "切换到英文"}
onClick={toggleLocale}
>
<span className={clsx(locale === "zh-CN" && "active")}></span>
<span className={clsx(locale === "en-US" && "active")}>EN</span>
</button>
<div className="live-badge" id="liveBadge">
<span className="pulse-dot" />
<span>{t("header.live")}</span>
</div>
<button
type="button"
className={clsx("refresh-btn", isRefreshing && "spinning")}
title={t("header.refreshAria")}
aria-label={t("header.refreshAria")}
onClick={handleRefresh}
>
<RotateCw size={16} strokeWidth={2} />
</button>
<Link
href={docsHref}
className={clsx("header-utility-btn", docsActive && "active")}
title={t("header.docsAria")}
aria-label={t("header.docsAria")}
>
<BookOpen size={14} strokeWidth={2} />
<span>{t("header.docs")}</span>
</Link>
<Link
href={accountHref}
className="header-utility-btn"
title={accountAria}
aria-label={accountAria}
>
{isAuthenticated ? <UserRound size={14} /> : <LogIn size={14} />}
</Link>
<button
type="button"
className="header-utility-btn more"
aria-label={locale === "en-US" ? "More actions" : "更多操作"}
title={locale === "en-US" ? "More actions" : "更多操作"}
>
<MoreHorizontal size={16} strokeWidth={2} />
</button>
{showRenewReminder ? (
<Link
href="/account"
className={clsx(
"account-renew-badge",
!proAccess.subscriptionActive && "expired",
)}
title={renewReminderLabel}
aria-label={renewReminderLabel}
>
<span>{renewReminderLabel}</span>
</Link>
) : null}
</div>
</header>
);
}
@@ -1,272 +0,0 @@
"use client";
import { CSSProperties, useEffect, useRef } from "react";
import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion";
export interface IntradaySignalMetric {
key: string;
label: string;
value: string;
hint: string;
fill: number | null;
tone: string;
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function getToneColor(tone: string) {
if (tone === "cyan") return "#4DA3FF";
if (tone === "blue") return "#60a5fa";
if (tone === "amber") return "#f59e0b";
return "#9FB2C7";
}
function hexToRgba(hex: string, alpha: number) {
const sanitized = hex.replace("#", "");
const numeric = Number.parseInt(sanitized.padEnd(6, "0"), 16);
const r = (numeric >> 16) & 255;
const g = (numeric >> 8) & 255;
const b = numeric & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/* ── Draw a rounded-rect path (polyfill-safe) ── */
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number | [number, number, number, number],
) {
const corners = typeof r === "number" ? [r, r, r, r] : r;
const [tl, tr, br, bl] = corners;
ctx.beginPath();
ctx.moveTo(x + tl, y);
ctx.lineTo(x + w - tr, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + tr);
ctx.lineTo(x + w, y + h - br);
ctx.quadraticCurveTo(x + w, y + h, x + w - br, y + h);
ctx.lineTo(x + bl, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - bl);
ctx.lineTo(x, y + tl);
ctx.quadraticCurveTo(x, y, x + tl, y);
ctx.closePath();
}
/* ── Component ── */
export function IntradaySignalScene({
metrics,
score,
}: {
metrics: IntradaySignalMetric[];
score: number;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const prefersReducedMotion = usePrefersReducedMotion();
useEffect(() => {
const host = containerRef.current;
if (!host) return;
const canvas = document.createElement("canvas");
canvas.style.cssText = "width:100%;height:100%;display:block;";
host.appendChild(canvas);
const ctx = canvas.getContext("2d")!;
let animationId = 0;
const resize = () => {
const rect = host.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(host);
let startTime = performance.now();
const render = (now: number) => {
animationId = requestAnimationFrame(render);
const W = host.clientWidth;
const H = host.clientHeight;
if (W < 1 || H < 1) return;
ctx.clearRect(0, 0, W, H);
const elapsed = prefersReducedMotion ? 0 : (now - startTime) / 1000;
/* ── Layout ── */
const cx = W / 2;
const floorY = Math.max(H - 28, H * 0.78);
const rx = clamp(Math.min(W / 2 - 24, 160), 60, 160);
const ry = clamp(rx * 0.28, 12, 26);
const isPositive = score >= 0;
const accentColor = isPositive ? "#4DA3FF" : "#F59E0B";
const floorGlowColor = isPositive ? "#0e7490" : "#b45309";
const floorBaseColor = isPositive ? "#10263b" : "#2c1d12";
/* ── Floor ── */
const floorGrad = ctx.createRadialGradient(cx, floorY, 0, cx, floorY, rx);
floorGrad.addColorStop(0, hexToRgba(floorGlowColor, 0.55));
floorGrad.addColorStop(0.5, floorBaseColor);
floorGrad.addColorStop(1, "#060e1a");
ctx.beginPath();
ctx.ellipse(cx, floorY, rx, ry, 0, 0, Math.PI * 2);
ctx.fillStyle = floorGrad;
ctx.fill();
/* Floor glow overlay */
const floorGlow = ctx.createRadialGradient(cx, floorY, 0, cx, floorY, rx * 0.6);
floorGlow.addColorStop(0, hexToRgba(accentColor, 0.08));
floorGlow.addColorStop(1, "transparent");
ctx.beginPath();
ctx.ellipse(cx, floorY, rx * 0.6, ry * 0.6, 0, 0, Math.PI * 2);
ctx.fillStyle = floorGlow;
ctx.fill();
/* ── Ring (oscillating opacity) ── */
const ringOpacity = 0.38 + Math.sin(elapsed * 0.8) * 0.1;
ctx.beginPath();
ctx.ellipse(cx, floorY, rx - 10, Math.max(ry - 3, 8), 0, 0, Math.PI * 2);
ctx.strokeStyle = hexToRgba(accentColor, ringOpacity);
ctx.lineWidth = 2.5;
ctx.stroke();
/* ── Bar positions ── */
const barSpacing = rx * 0.48;
const xPositions = [
cx - barSpacing,
cx - barSpacing / 3,
cx + barSpacing / 3,
cx + barSpacing,
];
const barWidth = Math.min(26, rx * 0.16);
const limitedMetrics = metrics.slice(0, 4);
/* ── Draw bars ── */
limitedMetrics.forEach((metric, i) => {
const fill = metric.fill ?? 20;
const height = Math.max(12, 14 + (fill / 100) * (floorY - 55));
const bx = xPositions[i];
const by = floorY - height;
const color = getToneColor(metric.tone);
const baseGlow = prefersReducedMotion
? 1
: 1 + Math.sin(elapsed * 1.2 + i) * 0.06;
/* Base glow ellipse */
const glowGrad = ctx.createRadialGradient(bx, floorY, 0, bx, floorY, barWidth * 1.2);
glowGrad.addColorStop(0, hexToRgba(color, 0.35));
glowGrad.addColorStop(1, "transparent");
ctx.beginPath();
ctx.ellipse(bx, floorY, barWidth * baseGlow, 7 * baseGlow, 0, 0, Math.PI * 2);
ctx.fillStyle = glowGrad;
ctx.fill();
/* Bar body (rounded top) */
const barGrad = ctx.createLinearGradient(bx, by, bx, floorY);
barGrad.addColorStop(0, color);
barGrad.addColorStop(0.55, color);
barGrad.addColorStop(1, hexToRgba(color, 0.3));
roundRect(ctx, bx - barWidth / 2, by, barWidth, height, [4, 4, 0, 0]);
ctx.fillStyle = barGrad;
ctx.fill();
/* Bar glow outline */
ctx.strokeStyle = hexToRgba(color, 0.15);
ctx.lineWidth = 1;
roundRect(ctx, bx - barWidth / 2 - 1, by - 1, barWidth + 2, height + 2, [5, 5, 0, 0]);
ctx.stroke();
/* Cap (pulse offset per bar) */
const pulse = prefersReducedMotion
? 0
: Math.sin(elapsed * 1.5 + i * 0.8) * 3;
const capY = by + pulse;
const capR = Math.max(5, barWidth * 0.22);
/* Cap outer glow */
const capGlow = ctx.createRadialGradient(bx, capY, 0, bx, capY, capR * 3);
capGlow.addColorStop(0, hexToRgba(color, 0.2));
capGlow.addColorStop(1, "transparent");
ctx.beginPath();
ctx.arc(bx, capY, capR * 3, 0, Math.PI * 2);
ctx.fillStyle = capGlow;
ctx.fill();
/* Cap body */
ctx.beginPath();
ctx.arc(bx, capY, capR, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = capR * 2;
ctx.fill();
ctx.shadowBlur = 0;
});
/* ── Ambient sparkle dots (only when not reduced motion) ── */
if (!prefersReducedMotion) {
for (let i = 0; i < 6; i++) {
const angle = elapsed * 0.25 + i * 1.05;
const dist = rx * 0.7 + Math.sin(elapsed * 0.15 + i * 2.3) * 10;
const sx = cx + Math.cos(angle) * dist;
const sy = floorY - ry * 0.4 + Math.sin(angle) * dist * 0.35;
const sparkleSize = 1.5 + Math.sin(elapsed * 2 + i * 1.7) * 0.8;
ctx.beginPath();
ctx.arc(sx, sy, Math.max(0.5, sparkleSize), 0, Math.PI * 2);
ctx.fillStyle = hexToRgba(accentColor, 0.12 + Math.sin(elapsed * 1.3 + i) * 0.06);
ctx.fill();
}
}
}
animationId = requestAnimationFrame(render);
return () => {
observer.disconnect();
cancelAnimationFrame(animationId);
if (canvas.parentNode === host) {
host.removeChild(canvas);
}
};
}, [metrics, prefersReducedMotion, score]);
return (
<div className="intraday-scene-shell">
<div
ref={containerRef}
className="intraday-scene-frame"
aria-hidden="true"
/>
<div className="intraday-scene-legend">
{metrics.slice(0, 4).map((metric) => (
<div key={metric.key} className="intraday-scene-chip">
<span
className="intraday-scene-chip-dot"
style={{ backgroundColor: getToneColor(metric.tone) } as CSSProperties}
/>
<div className="intraday-scene-chip-copy">
<strong>{metric.label}</strong>
<span>
{metric.value} · {metric.hint}
</span>
</div>
</div>
))}
</div>
</div>
);
}
@@ -1,39 +0,0 @@
"use client";
import "leaflet/dist/leaflet.css";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useLeafletMap } from "@/hooks/useLeafletMap";
export function MapCanvas({
onCitySelect,
selectionMode = "focus",
}: {
onCitySelect?: (cityName: string) => void;
selectionMode?: "focus" | "select";
} = {}) {
const store = useDashboardStore();
const { containerRef } = useLeafletMap({
cities: store.cities,
cityDetailsByName: store.cityDetailsByName,
citySummariesByName: store.citySummariesByName,
onClosePanel: store.closePanel,
onEnsureCityDetail: store.ensureCityDetail,
onMapInteractionChange: store.setMapInteractionActive,
onRegisterStopMotion: store.registerMapStopMotion,
onSelectCity: (cityName) => {
onCitySelect?.(cityName);
if (selectionMode === "select") {
void store.selectCity(cityName);
return;
}
void store.focusCity(cityName);
},
selectedCity: store.selectedCity,
selectedDetail: store.selectedDetail,
suspendMotion: Boolean(store.futureModalDate),
isLoadingDetail: store.loadingState.cityDetail,
});
return <div ref={containerRef} className="map" />;
}
@@ -1,7 +0,0 @@
"use client";
import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard";
export function PolyWeatherDashboard() {
return <ScanTerminalDashboard />;
}
@@ -1,116 +0,0 @@
"use client";
import React from "react";
import {
CircleDot,
Clock3,
Info,
TrendingUp,
Zap,
} from "lucide-react";
import { useI18n } from "@/hooks/useI18n";
import type { ScanTerminalFilters } from "@/lib/dashboard-types";
export interface FilterState extends ScanTerminalFilters {}
const SCAN_MODES = [
{
key: "tradable" as const,
icon: Zap,
labelEn: "Tradable",
labelZh: "可交易机会",
descEn: "Find the best immediate trade",
descZh: "发现当前最值得交易的市场",
},
{
key: "early" as const,
icon: Clock3,
labelEn: "Early",
labelZh: "早期机会",
descEn: "Long-horizon, lower-priced setups",
descZh: "长时间布局,低价市场",
},
{
key: "touch" as const,
icon: CircleDot,
labelEn: "Touch Play",
labelZh: "触达博弈",
descEn: "Markets approaching the settle line",
descZh: "接近决策,博弈是否触达",
},
{
key: "trend" as const,
icon: TrendingUp,
labelEn: "Trend",
labelZh: "趋势确认",
descEn: "Trend-confirmed follow-through",
descZh: "趋势明朗,顺势交易",
},
] as const;
export function ScanFilterPanel({
value,
onChange,
}: {
value: FilterState;
onChange?: (filters: FilterState) => void;
}) {
const { locale } = useI18n();
const isEn = locale === "en-US";
const updateFilter = <K extends keyof FilterState>(
key: K,
nextValue: FilterState[K],
) => {
onChange?.({
...value,
[key]: nextValue,
});
};
return (
<aside className="scan-filter-panel">
<div className="scan-sidebar-brand">
<div>
<div className="scan-sidebar-brand-name">PolyWeather</div>
</div>
</div>
<section className="scan-filter-section">
<div className="scan-filter-heading">
<span>{isEn ? "Scan Mode" : "扫描模式"}</span>
<Info size={14} />
</div>
<div className="scan-mode-tabs" role="tablist" aria-label={isEn ? "Scan mode" : "扫描模式"}>
{SCAN_MODES.map((mode) => {
const Icon = mode.icon;
const isActive = value.scan_mode === mode.key;
return (
<button
key={mode.key}
type="button"
role="tab"
aria-selected={isActive}
className={`scan-mode-tab ${isActive ? "active" : ""}`}
onClick={() => updateFilter("scan_mode", mode.key)}
>
<span className="scan-mode-icon">
<Icon size={16} />
</span>
<span className="scan-mode-copy">
<span className="scan-mode-tab-label">
{isEn ? mode.labelEn : mode.labelZh}
</span>
<span className="scan-mode-tab-sub">
{isEn ? mode.descEn : mode.descZh}
</span>
</span>
</button>
);
})}
</div>
</section>
</aside>
);
}
@@ -394,15 +394,11 @@ function CityGroupedTable({
{isExpanded && buckets.map((row) => (
<tr
key={row.id}
className={clsx(
"cursor-pointer border-b border-slate-50 bg-slate-50/50 hover:bg-blue-50/50",
selectedId === row.id && "bg-blue-50"
)}
onClick={() => onSelect(row)}
className="border-b border-slate-50 bg-slate-50/30 text-slate-500"
>
<td className="px-2 py-1" />
<td className="px-1.5 py-1 pl-5">
<span className="text-[10px] font-medium text-slate-700">{row.target_label || "--"}</span>
<span className="text-[10px]">{row.target_label || "--"}</span>
</td>
<td className={clsx("px-1.5 py-1 text-right font-mono text-[10px] font-bold", edgeClass(row.edge_percent))}>
{pct(row.edge_percent)}
@@ -1,451 +0,0 @@
"use client";
import { CSSProperties, useEffect, useMemo, useState } from "react";
import { useDashboardSelection } from "@/hooks/useDashboardStore";
import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion";
import { getWeatherAuraProfile } from "@/lib/weather-aura";
/* ──────────────────────────────────────────────────────────────
Pure-CSS weather-aura particle layer
Replaces the former Three.js WebGL implementation with
CSS @keyframes animations — no canvas, no WebGL.
────────────────────────────────────────────────────────────── */
function hexToRgba(hex: string, alpha: number) {
const sanitized = hex.replace("#", "");
const normalized =
sanitized.length === 3
? sanitized
.split("")
.map((char) => `${char}${char}`)
.join("")
: sanitized.padEnd(6, "0");
const numeric = Number.parseInt(normalized, 16);
const r = (numeric >> 16) & 255;
const g = (numeric >> 8) & 255;
const b = numeric & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/* ── Injection-safe style IDs ── */
const AURA_STYLE_ID = "poly-aura-keyframes";
/* ── CSS @keyframes (injected once via <style>) ── */
const PARTICLE_KEYFRAMES = `
@keyframes aura-float {
0% { transform: translate(0, 0); opacity: 0; }
10% { opacity: var(--p-op, 0.5); }
50% { transform: translate(var(--p-dx, 40px), var(--p-dy, -15px)); }
90% { opacity: var(--p-op, 0.5); }
100% { transform: translate(calc(var(--p-dx, 40px) * 1.4), var(--p-dy, 5px)); opacity: 0; }
}
@keyframes aura-rain {
0% { transform: translateY(-30px); opacity: 0; }
5% { opacity: 0.75; }
100% { transform: translateY(calc(100vh + 30px)); opacity: 0; }
}
@keyframes aura-snow {
0% { transform: translate(0, -20px); opacity: 0; }
15% { opacity: 0.9; }
50% { transform: translate(var(--s-sway, 25px), 50vh); }
100% { transform: translate(calc(var(--s-sway, 25px) * -1.2), calc(100vh + 20px)); opacity: 0; }
}
@keyframes aura-fog {
0% { transform: translateX(-30px); opacity: 0; }
20% { opacity: var(--f-op, 0.12); }
80% { opacity: var(--f-op, 0.12); }
100% { transform: translateX(calc(100vw + 30px)); opacity: 0; }
}
@keyframes aura-cloud {
0% { transform: translateX(-50px); opacity: 0; }
20% { opacity: var(--c-op, 0.1); }
80% { opacity: var(--c-op, 0.1); }
100% { transform: translateX(calc(100vw + 50px)); opacity: 0; }
}
@keyframes aura-storm-flash {
0%, 100% { opacity: 0; }
3% { opacity: 0.18; }
6% { opacity: 0; }
25% { opacity: 0; }
28% { opacity: 0.12; }
31% { opacity: 0; }
}
.aura-particle {
position: absolute;
pointer-events: none;
will-change: transform, opacity;
border-radius: 50%;
}
`;
/* ── Particle config type ── */
interface Particle {
id: number;
keyframe: string;
style: CSSProperties;
}
/* ── Particle generators ── */
function genFloatParticles(
count: number,
color: string,
baseOpacity: number,
sizeMin: number,
sizeMax: number,
idOffset: number,
intensity: number,
): Particle[] {
const out: Particle[] = [];
for (let i = 0; i < count; i++) {
const size = sizeMin + Math.random() * (sizeMax - sizeMin);
const op = baseOpacity * (0.35 + Math.random() * 0.65);
const dx = (15 + Math.random() * 50) * (intensity * 0.75);
const dy = (-4 - Math.random() * 14) * intensity;
out.push({
id: idOffset + i,
keyframe: "aura-float",
style: {
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
width: size,
height: size,
background: color,
boxShadow: `0 0 ${size * 3}px ${color}`,
"--p-op": op,
"--p-dx": `${dx}px`,
"--p-dy": `${dy}px`,
animationDuration: `${10 + Math.random() * 14}s`,
animationDelay: `${Math.random() * 8}s`,
animationTimingFunction: "ease-in-out",
animationIterationCount: "infinite",
animationName: "aura-float",
opacity: 0,
} as unknown as CSSProperties,
});
}
return out;
}
function genRainParticles(
count: number,
idOffset: number,
): Particle[] {
const out: Particle[] = [];
for (let i = 0; i < count; i++) {
const h = 8 + Math.random() * 10;
out.push({
id: idOffset + i,
keyframe: "aura-rain",
style: {
left: `${Math.random() * 100}%`,
top: `${-5 - Math.random() * 10}%`,
width: 1.5,
height: h,
borderRadius: "1px",
background: "linear-gradient(180deg, transparent, rgba(111, 183, 255, 0.85))",
opacity: 0.5 + Math.random() * 0.4,
animationDuration: `${0.5 + Math.random() * 0.5}s`,
animationDelay: `${Math.random() * 2}s`,
animationTimingFunction: "linear",
animationIterationCount: "infinite",
animationName: "aura-rain",
} as CSSProperties,
});
}
return out;
}
function genSnowParticles(
count: number,
idOffset: number,
): Particle[] {
const out: Particle[] = [];
for (let i = 0; i < count; i++) {
const size = 2.5 + Math.random() * 4.5;
const sway = 12 + Math.random() * 28;
out.push({
id: idOffset + i,
keyframe: "aura-snow",
style: {
left: `${Math.random() * 100}%`,
top: `${-10 - Math.random() * 15}%`,
width: size,
height: size,
background: "rgba(248, 250, 252, 0.92)",
boxShadow: `0 0 ${size * 2}px rgba(248, 250, 252, 0.35)`,
"--s-sway": `${sway}px`,
opacity: 0.6 + Math.random() * 0.35,
animationDuration: `${4.5 + Math.random() * 7}s`,
animationDelay: `${Math.random() * 5}s`,
animationTimingFunction: "ease-in-out",
animationIterationCount: "infinite",
animationName: "aura-snow",
} as unknown as CSSProperties,
});
}
return out;
}
function genFogParticles(
count: number,
intensity: number,
idOffset: number,
): Particle[] {
const out: Particle[] = [];
for (let i = 0; i < count; i++) {
const w = 40 + Math.random() * 70;
const h = 18 + Math.random() * 32;
out.push({
id: idOffset + i,
keyframe: "aura-fog",
style: {
left: `${-5 - Math.random() * 10}%`,
top: `${5 + Math.random() * 75}%`,
width: w,
height: h,
background: `radial-gradient(circle, rgba(203, 213, 225, 0.12), transparent 70%)`,
filter: `blur(${8 + Math.random() * 10}px)`,
borderRadius: "50%",
"--f-op": (0.08 + Math.random() * 0.08) * intensity,
animationDuration: `${18 + Math.random() * 18}s`,
animationDelay: `${Math.random() * 12}s`,
animationTimingFunction: "linear",
animationIterationCount: "infinite",
animationName: "aura-fog",
opacity: 0,
} as unknown as CSSProperties,
});
}
return out;
}
function genCloudParticles(
count: number,
idOffset: number,
): Particle[] {
const out: Particle[] = [];
for (let i = 0; i < count; i++) {
const w = 60 + Math.random() * 90;
const h = 22 + Math.random() * 38;
out.push({
id: idOffset + i,
keyframe: "aura-cloud",
style: {
left: `${-10 - Math.random() * 15}%`,
top: `${5 + Math.random() * 45}%`,
width: w,
height: h,
background: `radial-gradient(circle, rgba(219, 234, 254, 0.08), transparent 70%)`,
filter: `blur(${14 + Math.random() * 12}px)`,
borderRadius: "50%",
"--c-op": 0.06 + Math.random() * 0.06,
animationDuration: `${28 + Math.random() * 22}s`,
animationDelay: `${Math.random() * 14}s`,
animationTimingFunction: "linear",
animationIterationCount: "infinite",
animationName: "aura-cloud",
opacity: 0,
} as unknown as CSSProperties,
});
}
return out;
}
function genWindParticles(
count: number,
color: string,
idOffset: number,
): Particle[] {
const out: Particle[] = [];
for (let i = 0; i < count; i++) {
const size = 1.5 + Math.random() * 2.5;
out.push({
id: idOffset + i,
keyframe: "aura-float",
style: {
left: `${-5 - Math.random() * 8}%`,
top: `${Math.random() * 100}%`,
width: size,
height: size,
background: color,
boxShadow: `0 0 ${size * 2}px ${color}`,
"--p-op": 0.35 + Math.random() * 0.3,
"--p-dx": `${120 + Math.random() * 180}px`,
"--p-dy": `${-2 + Math.random() * 4}px`,
animationDuration: `${2.5 + Math.random() * 3.5}s`,
animationDelay: `${Math.random() * 3}s`,
animationTimingFunction: "linear",
animationIterationCount: "infinite",
animationName: "aura-float",
opacity: 0,
} as unknown as CSSProperties,
});
}
return out;
}
/* ── Component ── */
export function WeatherAuraLayer() {
const { cities, selectedDetail } = useDashboardSelection();
const prefersReducedMotion = usePrefersReducedMotion();
const [isDesktop, setIsDesktop] = useState(false);
const aura = getWeatherAuraProfile(selectedDetail, cities);
/* Inject keyframes once */
useEffect(() => {
if (document.getElementById(AURA_STYLE_ID)) return;
const el = document.createElement("style");
el.id = AURA_STYLE_ID;
el.textContent = PARTICLE_KEYFRAMES;
document.head.appendChild(el);
return () => {
const existing = document.getElementById(AURA_STYLE_ID);
if (existing) existing.remove();
};
}, []);
/* Desktop media query */
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(min-width: 1024px)");
const apply = () => setIsDesktop(mq.matches);
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}, []);
/* Build particles */
const particles = useMemo(() => {
if (!isDesktop || prefersReducedMotion) return [];
const list: Particle[] = [];
/* Primary float layer */
list.push(
...genFloatParticles(30, aura.primary, aura.particleOpacity * 0.9, 2, 4, 0, aura.intensity),
);
/* Secondary float layer */
list.push(
...genFloatParticles(
20,
aura.secondary,
aura.particleOpacity * 0.6,
3,
6,
100,
aura.intensity * 0.8,
),
);
switch (aura.effect) {
case "rain": {
list.push(...genRainParticles(25, 200));
break;
}
case "storm": {
list.push(...genRainParticles(35, 200));
break;
}
case "snow": {
list.push(...genSnowParticles(25, 200));
break;
}
case "fog": {
list.push(...genFogParticles(15, aura.effectIntensity, 200));
break;
}
case "cloud": {
list.push(...genCloudParticles(12, 200));
break;
}
case "wind": {
list.push(...genWindParticles(30, aura.primary, 200));
break;
}
}
/* Storm flash overlay */
if (aura.effect === "storm") {
list.push({
id: 999,
keyframe: "aura-storm-flash",
style: {
position: "absolute" as const,
inset: 0,
width: "100%",
height: "100%",
borderRadius: 0,
background:
"radial-gradient(ellipse at 50% 30%, rgba(219, 234, 254, 0.25), transparent 60%)",
animationDuration: "4s",
animationTimingFunction: "step-end",
animationIterationCount: "infinite",
animationName: "aura-storm-flash",
opacity: 0,
} as CSSProperties,
});
}
return list;
}, [aura, isDesktop, prefersReducedMotion]);
if (!isDesktop) {
return null;
}
const overlayStyle: CSSProperties = {
position: "absolute",
inset: 0,
zIndex: 2,
pointerEvents: "none",
opacity: 0.96,
overflow: "hidden",
backgroundImage: [
`radial-gradient(circle at 18% 22%, ${hexToRgba(aura.primary, 0.18 * aura.intensity)}, transparent 32%)`,
`radial-gradient(circle at 78% 20%, ${hexToRgba(aura.secondary, 0.14 * aura.intensity)}, transparent 34%)`,
`radial-gradient(circle at 52% 78%, ${hexToRgba(aura.tertiary, 0.12 * aura.intensity)}, transparent 38%)`,
aura.effect === "rain" || aura.effect === "storm"
? `linear-gradient(180deg, ${hexToRgba("#6FB7FF", 0.06 * aura.effectIntensity)}, transparent 45%)`
: aura.effect === "snow"
? `linear-gradient(180deg, ${hexToRgba("#e2e8f0", 0.06 * aura.effectIntensity)}, transparent 45%)`
: aura.effect === "fog"
? `radial-gradient(circle at 50% 56%, ${hexToRgba("#cbd5e1", 0.08 * aura.effectIntensity)}, transparent 60%)`
: aura.effect === "cloud"
? `linear-gradient(180deg, ${hexToRgba("#dbeafe", 0.04 * aura.effectIntensity)}, transparent 40%)`
: "none",
].join(", "),
};
const scrimStyle: CSSProperties = {
position: "absolute",
inset: 0,
backgroundImage: [
"linear-gradient(180deg, rgba(3, 8, 19, 0.64) 0%, rgba(5, 10, 20, 0.16) 24%, rgba(4, 8, 18, 0.1) 54%, rgba(3, 6, 14, 0.42) 100%)",
"radial-gradient(circle at 50% 60%, rgba(0, 224, 164, 0.08) 0%, rgba(123, 97, 255, 0) 48%)",
].join(", "),
};
return (
<div
aria-hidden="true"
data-reduced-motion={prefersReducedMotion ? "true" : "false"}
style={overlayStyle}
>
{particles.map((p) => (
<div key={p.id} className="aura-particle" style={p.style} />
))}
<div style={scrimStyle} />
</div>
);
}
@@ -1,213 +0,0 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { formatQuoteCents } from "./opportunity-format";
import { getLocalizedRowText } from "./opportunity-copy";
import { getModelSourceSummary } from "./opportunity-model-summary";
export function getOpportunityStrength(edgePercent?: number | null, locale = "zh-CN") {
const edge = Number(edgePercent);
const normalized = Number.isFinite(edge) ? edge : 0;
if (normalized >= 20) {
return {
label: locale === "en-US" ? "High confidence" : "高胜率",
tone: "strong",
};
}
if (normalized >= 10) {
return {
label: locale === "en-US" ? "Medium confidence" : "中等胜率",
tone: "medium",
};
}
return {
label: locale === "en-US" ? "Watch" : "观察",
tone: "watch",
};
}
export function getShortAiConclusion(
row: ScanOpportunityRow,
locale: string,
_edgePercent?: number | null,
strengthLabel?: string,
) {
const directReason =
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
getLocalizedRowText(
row,
locale,
row.ai_watchlist_reason_zh,
row.ai_watchlist_reason_en,
);
if (directReason) return directReason;
const cityThesis = getLocalizedRowText(
row,
locale,
row.ai_city_thesis_zh,
row.ai_city_thesis_en,
);
if (cityThesis) return cityThesis;
const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
if (locale === "en-US") {
return `${strengthLabel || "Watch"}: AI should validate against ${modelBasis}.`;
}
return `${strengthLabel || "观察"}AI 需结合${modelBasis}确认。`;
}
export function getRiskHints(
row: ScanOpportunityRow,
locale: string,
modelProbability?: number | null,
) {
const hints: string[] = [];
const spread = Number(row.spread);
if (Number.isFinite(spread) && spread > 0.03) {
hints.push(
locale === "en-US"
? `Wide spread ${formatQuoteCents(spread)} may distort the displayed market price.`
: `盘口价差 ${formatQuoteCents(spread)} 偏宽,可能扭曲市场价格参考。`,
);
}
const quoteAgeSeconds =
row.quote_age_ms != null && Number.isFinite(Number(row.quote_age_ms))
? Math.round(Number(row.quote_age_ms) / 1000)
: null;
if (quoteAgeSeconds != null && quoteAgeSeconds > 60) {
hints.push(
locale === "en-US"
? `Quote age ${quoteAgeSeconds}s; refresh before acting.`
: `报价已 ${quoteAgeSeconds}s,执行前需要刷新。`,
);
}
if (row.trend_alignment === false) {
hints.push(
locale === "en-US"
? "Intraday trend does not fully support this direction."
: "日内趋势未完全支持该方向。",
);
}
if (row.cluster_adjusted) {
hints.push(
locale === "en-US"
? "Tail bucket was cluster-adjusted; bucket confidence may be overstated."
: "尾部桶已做模型集群折扣,温度桶信心可能偏乐观。",
);
}
if (modelProbability != null && modelProbability < 10) {
hints.push(
locale === "en-US"
? "Low model probability makes the setup sensitive to calibration error."
: "模型概率偏低,校准误差会显著影响判断。",
);
}
if (!hints.length) {
hints.push(
locale === "en-US"
? "Main residual risk is late observation updates or a shifted peak window."
: "主要残余风险是后续实测升温或峰值窗口漂移。",
);
}
return hints;
}
export function getRecommendationReasons(
row: ScanOpportunityRow,
locale: string,
_edgePercent?: number | null,
price?: number | null,
) {
const reasons: string[] = [];
const aiReason = getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en);
if (aiReason && String(row.ai_decision || "").toLowerCase() === "approve") {
reasons.push(aiReason);
}
const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
reasons.push(
locale === "en-US"
? `AI uses ${modelBasis} with market ask ${formatQuoteCents(price)} only as downstream bucket context.`
: `AI 以${modelBasis}为主,市场买价 ${formatQuoteCents(price)} 只作下游温度桶参考。`,
);
if (row.peak_alignment_score != null) {
reasons.push(
locale === "en-US"
? `Peak alignment score ${Number(row.peak_alignment_score).toFixed(2)} supports checking this bucket.`
: `峰值对齐分 ${Number(row.peak_alignment_score).toFixed(2)},支持把该桶纳入检查。`,
);
}
return reasons.slice(0, 3);
}
export function getExclusionReasons(
row: ScanOpportunityRow,
locale: string,
edgePercent?: number | null,
) {
const decision = String(row.ai_decision || "").toLowerCase();
const aiReason =
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
getLocalizedRowText(
row,
locale,
row.ai_watchlist_reason_zh,
row.ai_watchlist_reason_en,
);
if (decision === "veto" || decision === "downgrade" || decision === "watchlist") {
return [
aiReason ||
(locale === "en-US"
? "AI did not classify this row as the primary forecast bucket."
: "AI 未把该合约列为主预测桶。"),
];
}
if (edgePercent != null && Number(edgePercent) < 10) {
return [
locale === "en-US"
? "This bucket is not the current forecast center."
: "该桶不是当前预测中枢。",
];
}
return [
locale === "en-US"
? "No hard veto in the current AI/rule snapshot."
: "当前 AI/规则快照没有硬性排除项。",
];
}
export function getAiMeta(row: ScanOpportunityRow, locale: string) {
const decision = String(row.ai_decision || "").toLowerCase();
if (decision === "veto") {
return {
label: locale === "en-US" ? "AI veto" : "AI 排除",
tone: "veto",
reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
};
}
if (decision === "downgrade") {
return {
label: locale === "en-US" ? "AI downgrade" : "AI 降级",
tone: "downgrade",
reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
};
}
if (row.ai_rank != null || decision === "approve") {
return {
label: locale === "en-US" ? `AI pick ${row.ai_rank || ""}`.trim() : `AI 推荐 ${row.ai_rank || ""}`.trim(),
tone: "approve",
reason:
(locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en) ||
row.ai_model_cluster_note ||
null,
};
}
if (decision === "watchlist") {
return {
label: locale === "en-US" ? "AI watch" : "AI 观察",
tone: "downgrade",
reason:
locale === "en-US"
? row.ai_watchlist_reason_en || row.ai_watchlist_reason_zh
: row.ai_watchlist_reason_zh || row.ai_watchlist_reason_en,
};
}
return null;
}
@@ -1,127 +0,0 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import { formatTemperatureValue } from "@/lib/temperature-utils";
import { formatTemperatureDelta } from "./opportunity-format";
import { getMetarObservationContext } from "./opportunity-observation";
import { getTargetRange } from "./opportunity-target";
export function getDebDistanceSummary(
row: ScanOpportunityRow,
locale: string,
tempSymbol?: string | null,
) {
const deb =
row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
? Number(row.deb_prediction)
: null;
if (deb == null) return locale === "en-US" ? "DEB pending" : "DEB 待确认";
const { lower, upper } = getTargetRange(row);
if (lower != null && upper == null) {
const delta = deb - lower;
if (Math.abs(delta) < 0.05) return locale === "en-US" ? "DEB on threshold" : "DEB 贴近阈值";
return delta >= 0
? locale === "en-US"
? `DEB above by ${formatTemperatureDelta(delta, tempSymbol)}`
: `DEB 高于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`
: locale === "en-US"
? `DEB below by ${formatTemperatureDelta(delta, tempSymbol)}`
: `DEB 低于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`;
}
if (upper != null && lower == null) {
const delta = deb - upper;
if (Math.abs(delta) < 0.05) return locale === "en-US" ? "DEB on threshold" : "DEB 贴近阈值";
return delta <= 0
? locale === "en-US"
? `DEB below by ${formatTemperatureDelta(delta, tempSymbol)}`
: `DEB 低于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`
: locale === "en-US"
? `DEB above by ${formatTemperatureDelta(delta, tempSymbol)}`
: `DEB 高于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`;
}
if (lower != null && upper != null) {
if (deb >= lower && deb <= upper) return locale === "en-US" ? "DEB inside bucket" : "DEB 位于桶内";
const nearest = deb < lower ? lower : upper;
const delta = deb - nearest;
return deb < lower
? locale === "en-US"
? `DEB below bucket by ${formatTemperatureDelta(delta, tempSymbol)}`
: `DEB 低于桶 ${formatTemperatureDelta(delta, tempSymbol)}`
: locale === "en-US"
? `DEB above bucket by ${formatTemperatureDelta(delta, tempSymbol)}`
: `DEB 高于桶 ${formatTemperatureDelta(delta, tempSymbol)}`;
}
return locale === "en-US"
? `DEB ${formatTemperatureValue(deb, tempSymbol, { digits: 1 })}`
: `DEB ${formatTemperatureValue(deb, tempSymbol, { digits: 1 })}`;
}
export function getModelSupportSummary(
row: ScanOpportunityRow,
locale: string,
) {
const sources = Object.values(row.model_cluster_sources || {})
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
const deb =
row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
? Number(row.deb_prediction)
: null;
if (!sources.length || deb == null) return locale === "en-US" ? "Models pending" : "模型待确认";
const { lower, upper } = getTargetRange(row);
let supports = 0;
if (lower != null && upper == null) {
supports = sources.filter((value) => (deb >= lower ? value >= lower : value < lower)).length;
} else if (upper != null && lower == null) {
supports = sources.filter((value) => (deb <= upper ? value <= upper : value > upper)).length;
} else if (lower != null && upper != null) {
if (deb >= lower && deb <= upper) {
supports = sources.filter((value) => value >= lower && value <= upper).length;
} else if (deb < lower) {
supports = sources.filter((value) => value < lower).length;
} else {
supports = sources.filter((value) => value > upper).length;
}
} else {
const tolerance = 1;
supports = sources.filter((value) => Math.abs(value - deb) <= tolerance).length;
}
return locale === "en-US"
? `${supports}/${sources.length} models support DEB`
: `${supports}/${sources.length} 模型支持 DEB`;
}
export function getMetarConflictSummary(
row: ScanOpportunityRow,
detail: CityDetail | null,
locale: string,
) {
const obs = getMetarObservationContext(row, detail);
if (obs.stale || obs.maxTemp == null) return locale === "en-US" ? "METAR pending" : "METAR 待确认";
const deb =
row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
? Number(row.deb_prediction)
: null;
const { lower, upper } = getTargetRange(row);
if (deb == null || (lower == null && upper == null)) {
return locale === "en-US" ? "METAR read only" : "METAR 仅参考";
}
const phase = String(row.window_phase || "").toLowerCase();
const peakPending =
phase === "early_today" ||
phase === "setup_today" ||
(row.minutes_until_peak_start != null && Number(row.minutes_until_peak_start) > 0);
if (lower != null && upper == null) {
if (deb < lower && obs.maxTemp >= lower) return locale === "en-US" ? "METAR conflicts" : "METAR 冲突";
if (deb >= lower && obs.maxTemp < lower && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值";
return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突";
}
if (upper != null && lower == null) {
if (deb <= upper && obs.maxTemp > upper) return locale === "en-US" ? "METAR conflicts" : "METAR 冲突";
if (deb > upper && obs.maxTemp <= upper && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值";
return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突";
}
if (lower != null && upper != null && deb >= lower && deb <= upper) {
if (obs.maxTemp > upper) return locale === "en-US" ? "METAR above bucket" : "METAR 已越过桶";
if (obs.maxTemp < lower && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值";
}
return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突";
}
@@ -1,293 +0,0 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import {
formatTemperatureValue,
normalizeTemperatureSymbol,
} from "@/lib/temperature-utils";
import { formatAirportReportRead } from "./opportunity-airport-read";
import { getLocalizedRowText } from "./opportunity-copy";
import { getBucketDisplayLabel } from "./opportunity-groups";
import { getMetarGate } from "./opportunity-observation";
import { getTargetRange } from "./opportunity-target";
import {
getForecastContractFit,
getForecastRangeLabel,
getPaceDecisionTail,
} from "./opportunity-v4-forecast";
import type { V4CityForecast, V4TradeDecision } from "./opportunity-v4-types";
export function getV4DecisionLabel(
decision: V4TradeDecision["decision"],
locale: string,
) {
if (locale === "en-US") {
if (decision === "approve") return "AI Confirmed";
if (decision === "veto") return "AI Outside";
if (decision === "downgrade") return "AI Downgrade";
return "AI Watch";
}
if (decision === "approve") return "AI 确认";
if (decision === "veto") return "AI 区间外";
if (decision === "downgrade") return "AI 降级";
return "AI 观察";
}
export function getV4TradeDecision(
row: ScanOpportunityRow,
detail: CityDetail | null,
locale: string,
edgePercent?: number | null,
tempSymbol?: string | null,
): V4TradeDecision {
const isEn = locale === "en-US";
const backendMetarDecision = String(row.v4_metar_decision || "").toLowerCase();
const backendMetarReason =
getLocalizedRowText(row, locale, row.v4_metar_reason_zh, row.v4_metar_reason_en) ||
null;
const metarGate = getMetarGate(row, detail, locale, tempSymbol);
const aiDecision = String(row.ai_decision || "").toLowerCase();
const aiReason =
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
getLocalizedRowText(
row,
locale,
row.ai_watchlist_reason_zh,
row.ai_watchlist_reason_en,
);
let decision: V4TradeDecision["decision"] =
backendMetarDecision === "veto" ||
backendMetarDecision === "downgrade" ||
backendMetarDecision === "approve" ||
backendMetarDecision === "watchlist"
? (backendMetarDecision as V4TradeDecision["decision"])
: metarGate?.decision ||
(aiDecision === "veto" || aiDecision === "downgrade" || aiDecision === "approve" || aiDecision === "watchlist"
? (aiDecision as V4TradeDecision["decision"])
: Number(edgePercent || 0) >= 20
? "watchlist"
: "watchlist");
if (metarGate?.decision === "veto") decision = "veto";
if (metarGate?.decision === "watchlist" && backendMetarDecision === "downgrade") {
decision = "watchlist";
}
if (metarGate?.decision === "downgrade" && decision !== "veto") decision = "downgrade";
if (metarGate?.decision === "approve" && decision !== "veto" && decision !== "downgrade") {
decision = "approve";
}
const reason =
(metarGate?.decision === "watchlist" ? metarGate.reason : null) ||
backendMetarReason ||
metarGate?.reason ||
aiReason ||
(isEn
? "AI keeps this on watch until METAR and the full weather-model cluster align."
: "AI 会等 METAR 与全量天气模型集群对齐后再确认。");
const airportReport = formatAirportReportRead(
row,
detail,
locale,
normalizeTemperatureSymbol(tempSymbol),
);
const metarSummary =
metarGate?.evidence?.filter((item) => item !== airportReport).join(" · ") || null;
return {
decision,
label: getV4DecisionLabel(decision, locale),
tone: decision,
reason,
metarSummary,
airportReport,
metarEvidence: metarGate?.evidence || [],
};
}
export function getForecastFitMeta(
fit: ReturnType<typeof getForecastContractFit>,
locale: string,
) {
const isEn = locale === "en-US";
const tone = String(fit.tone || "watchlist");
if (tone === "approve" || tone === "core") {
return {
label: isEn ? "Clear signal" : "方向明确",
tone: "approve",
};
}
if (tone === "veto" || tone === "outside") {
return {
label: isEn ? "Outside AI range" : "偏离 AI 区间",
tone: "veto",
};
}
if (tone === "downgrade") {
return {
label: isEn ? "Downgraded" : "降级观察",
tone: "downgrade",
};
}
return {
label: isEn ? "Need peak confirmation" : "等待峰值确认",
tone: "watchlist",
};
}
export function getThresholdDecision(
row: ScanOpportunityRow,
forecast: V4CityForecast,
locale: string,
tempSymbol?: string | null,
) {
const isEn = locale === "en-US";
const unit = normalizeTemperatureSymbol(tempSymbol);
const { lower, upper } = getTargetRange(row);
const predicted = forecast.predicted;
const low = forecast.low;
const high = forecast.high;
const tolerance = unit === "°F" ? 1 : 0.5;
const cautionBand = unit === "°F" ? 2 : 1;
const format = (value: number) => formatTemperatureValue(value, unit, { digits: 0 });
const paceTolerance = unit === "°F" ? 1 : 0.6;
const paceTail = getPaceDecisionTail(forecast, locale, unit);
const paceAdjustedHigh =
forecast.paceAdjustedHigh != null && Number.isFinite(Number(forecast.paceAdjustedHigh))
? Number(forecast.paceAdjustedHigh)
: null;
const runningHot =
forecast.paceDelta != null && Number(forecast.paceDelta) >= paceTolerance;
const runningCold =
forecast.paceDelta != null && Number(forecast.paceDelta) <= -paceTolerance;
const confidence = (() => {
const values = Object.values(row.model_cluster_sources || {})
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
if (!values.length || predicted == null) return isEn ? "Medium" : "中";
const near = values.filter((value) => Math.abs(value - predicted) <= cautionBand).length;
const ratio = near / values.length;
if (ratio >= 0.75) return isEn ? "High" : "高";
if (ratio >= 0.45) return isEn ? "Medium" : "中";
return isEn ? "Low" : "低";
})();
if (predicted == null || (lower == null && upper == null)) {
return {
confidence,
headline: isEn ? "Conclusion pending" : "结论待确认",
relation: isEn ? "Await stable forecast" : "等待稳定预测",
summary: isEn
? "AI does not have a stable high-temperature center yet."
: "AI 还没有稳定的最高温中枢,先不输出边界结论。",
tone: "watchlist" as const,
};
}
if (lower != null && upper == null) {
const threshold = format(lower);
if (
predicted < lower - cautionBand &&
(high == null || high < lower + tolerance) &&
(paceAdjustedHigh == null || paceAdjustedHigh < lower - tolerance)
) {
return {
confidence,
headline: isEn ? `Unlikely to reach ${threshold}` : `不太可能达到 ${threshold}`,
relation: isEn ? "Clearly below threshold" : "明显低于阈值",
summary: isEn
? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}, below the ${threshold} boundary with no clear breakout signal.${paceTail}`
: `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},低于 ${threshold} 阈值,暂时缺乏明显突破信号。${paceTail}`,
tone: "veto" as const,
};
}
if (
predicted >= lower + tolerance &&
(low == null || low >= lower - tolerance) &&
!runningCold &&
(paceAdjustedHigh == null || paceAdjustedHigh >= lower - tolerance)
) {
return {
confidence,
headline: isEn ? `Likely to reach ${threshold}` : `大概率达到 ${threshold}`,
relation: isEn ? "Above threshold" : "高于阈值",
summary: isEn
? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}, already above the ${threshold} boundary.${paceTail}`
: `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},已经高于 ${threshold} 阈值。${paceTail}`,
tone: "approve" as const,
};
}
return {
confidence,
headline: isEn ? `${threshold} boundary is risky` : `${threshold} 边界偏危险`,
relation: isEn ? "Near threshold" : "接近阈值(存在突破风险)",
summary: isEn
? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}; the ${threshold} boundary still needs peak-window confirmation.${paceTail}`
: `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},接近 ${threshold} 阈值,仍要等峰值窗口确认。${paceTail}`,
tone: "watchlist" as const,
};
}
if (upper != null && lower == null) {
const threshold = format(upper);
if (
predicted <= upper - tolerance &&
(high == null || high <= upper + tolerance) &&
!runningHot &&
(paceAdjustedHigh == null || paceAdjustedHigh <= upper + tolerance)
) {
return {
confidence,
headline: isEn ? `Likely to stay below ${threshold}` : `大概率不超过 ${threshold}`,
relation: isEn ? "Clearly below threshold" : "明显低于阈值",
summary: isEn
? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}, below the ${threshold} boundary.${paceTail}`
: `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},低于 ${threshold} 阈值。${paceTail}`,
tone: "approve" as const,
};
}
if (
predicted > upper + cautionBand &&
(low == null || low > upper - tolerance) &&
(paceAdjustedHigh == null || paceAdjustedHigh > upper + tolerance)
) {
return {
confidence,
headline: isEn ? `Likely above ${threshold}` : `大概率超过 ${threshold}`,
relation: isEn ? "Above threshold" : "高于阈值",
summary: isEn
? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}, above the ${threshold} boundary.${paceTail}`
: `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},高于 ${threshold} 阈值。${paceTail}`,
tone: "veto" as const,
};
}
return {
confidence,
headline: isEn ? `${threshold} boundary is risky` : `${threshold} 边界偏危险`,
relation: isEn ? "Near threshold" : "接近阈值(存在突破风险)",
summary: isEn
? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}; the boundary still needs peak-window confirmation.${paceTail}`
: `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},仍需等待峰值窗口确认边界。${paceTail}`,
tone: "watchlist" as const,
};
}
const bucket = getBucketDisplayLabel(row, locale, unit);
const bucketReference = paceAdjustedHigh ?? predicted;
const inside =
(lower == null || bucketReference >= lower - tolerance) &&
(upper == null || bucketReference <= upper + tolerance);
return {
confidence,
headline: inside
? isEn
? `Likely inside ${bucket}`
: `大概率落在 ${bucket}`
: isEn
? `Unlikely inside ${bucket}`
: `不太可能落在 ${bucket}`,
relation: inside ? (isEn ? "Inside target bucket" : "处于目标桶") : (isEn ? "Outside target bucket" : "偏离目标桶"),
summary: isEn
? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}; use this bucket only as the market mapping of the city high.${paceTail}`
: `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},该温度桶只用于映射城市最高温判断。${paceTail}`,
tone: inside ? ("approve" as const) : ("veto" as const),
};
}
@@ -1,104 +0,0 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import {
formatTemperatureValue,
normalizeTemperatureSymbol,
} from "@/lib/temperature-utils";
import { formatModelClusterRange } from "./opportunity-model-summary";
import { getMetarObservationContext } from "./opportunity-observation";
import type { V4CityForecast } from "./opportunity-v4-types";
export function getForecastRiskItems(
row: ScanOpportunityRow,
detail: CityDetail | null,
forecast: V4CityForecast,
locale: string,
tempSymbol?: string | null,
) {
const isEn = locale === "en-US";
const obs = getMetarObservationContext(row, detail);
const risks: string[] = [];
const phase = String(row.window_phase || "").toLowerCase();
if (
phase === "early_today" ||
phase === "setup_today" ||
(row.minutes_until_peak_start != null && Number(row.minutes_until_peak_start) > 0)
) {
risks.push(
isEn
? "Peak window has not arrived; current METAR is only path evidence."
: "峰值窗口尚未到达,当前 METAR 只能作为路径证据。",
);
}
if (row.trend_alignment === false) {
risks.push(
isEn
? "Intraday observation path does not fully support the forecast center."
: "日内实况路径还没有完全支持预测中枢。",
);
}
if (forecast.paceRead && forecast.paceTone !== "neutral") {
risks.push(
isEn
? `Observed pace is deviating from the DEB curve: ${forecast.paceRead}`
: `实测节奏正在偏离 DEB 曲线:${forecast.paceRead}`,
);
}
if (obs.stale || obs.lastTemp == null) {
risks.push(
isEn
? "Same-day METAR confirmation is still weak."
: "同日 METAR 确认仍然偏弱。",
);
}
if (forecast.low != null && forecast.high != null) {
const spread = Math.abs(forecast.high - forecast.low);
const wide = String(normalizeTemperatureSymbol(tempSymbol)).toUpperCase().includes("F")
? spread > 2
: spread > 1;
if (wide) {
risks.push(
isEn
? "Model range is wide; treat boundary buckets conservatively."
: "模型区间偏宽,边界温度桶需要保守处理。",
);
}
}
if (!risks.length) {
risks.push(
isEn
? "Residual risk is late METAR revision or a shifted afternoon peak."
: "残余风险主要是后续 METAR 修订或峰值窗口漂移。",
);
}
return Array.from(new Set(risks)).slice(0, 3);
}
export function getDecisionReasonItems(
row: ScanOpportunityRow,
forecast: V4CityForecast,
modelSupportText: string,
locale: string,
tempSymbol?: string | null,
) {
const isEn = locale === "en-US";
const modelRange = formatModelClusterRange(row.model_cluster_sources, tempSymbol);
const reasons: string[] = [];
if (modelRange !== "--") {
reasons.push(
isEn
? `Model cluster sits around ${modelRange}; ${modelSupportText}.`
: `模型区间集中在 ${modelRange}${modelSupportText}`,
);
}
if (forecast.predicted != null) {
reasons.push(
isEn
? `AI high-temperature center is ${formatTemperatureValue(forecast.predicted, tempSymbol, { digits: 1 })}.`
: `AI 最高温中枢约 ${formatTemperatureValue(forecast.predicted, tempSymbol, { digits: 1 })}。`,
);
}
if (forecast.paceRead) reasons.push(forecast.paceRead);
if (forecast.peakWindow) reasons.push(forecast.peakWindow);
if (forecast.weatherRead) reasons.push(forecast.weatherRead);
return Array.from(new Set(reasons)).slice(0, 3);
}