"use client"; import { ChartConfiguration } from "chart.js/auto"; import clsx from "clsx"; import { useChart } from "@/hooks/useChart"; import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; import { CityDetail, MarketScan, MarketTopBucket, ProbabilityBucket, } from "@/lib/dashboard-types"; import { getHeroMetaItems, getModelView, getProbabilityView, getRiskBadgeLabel, getTemperatureChartData, getWeatherSummary, parseAiAnalysis, } from "@/lib/dashboard-utils"; function EmptyState({ text }: { text: string }) { return (
{text}
); } function toPercent(value?: number | null) { const numeric = Number(value); if (!Number.isFinite(numeric)) return null; return `${(numeric * 100).toFixed(1)}%`; } function toPriceCents(value?: number | null) { const numeric = Number(value); if (!Number.isFinite(numeric)) return null; const normalized = numeric > 1 ? numeric / 100 : numeric; const cents = normalized * 100; const rounded = Math.round(cents * 10) / 10; const text = Number.isInteger(rounded) ? String(rounded.toFixed(0)) : String(rounded); return `${text}c`; } function parseTempFromText(value: unknown) { const text = String(value || ""); const match = text.match(/(-?\d+(?:\.\d+)?)/); if (!match) return null; const numeric = Number(match[1]); return Number.isFinite(numeric) ? numeric : null; } function getBucketTemp(bucket: ProbabilityBucket) { const byValue = Number(bucket.value); if (Number.isFinite(byValue)) return byValue; return parseTempFromText(bucket.label || bucket.bucket || bucket.range); } function getMarketBucketTemp(scan?: MarketScan | null) { if (!scan) return null; const byBucketValue = Number(scan.temperature_bucket?.value); if (Number.isFinite(byBucketValue)) return byBucketValue; const byBucketLabel = parseTempFromText( scan.temperature_bucket?.label || scan.temperature_bucket?.bucket || scan.temperature_bucket?.range, ); if (byBucketLabel != null) return byBucketLabel; const slug = String(scan.selected_slug || scan.primary_market?.slug || ""); const slugMatch = slug.match(/-(-?\d+(?:\.\d+)?)c(?:$|[^a-z0-9])/i); if (slugMatch) { const numeric = Number(slugMatch[1]); if (Number.isFinite(numeric)) return numeric; } return parseTempFromText(scan.primary_market?.question); } function getMarketYesPrice(scan?: MarketScan | null) { const preferred = Number(scan?.market_price); if (Number.isFinite(preferred)) return preferred; const implied = Number(scan?.yes_token?.implied_probability); return Number.isFinite(implied) ? implied : null; } function getMarketNoPrice(scan?: MarketScan | null) { const direct = Number(scan?.no_buy); if (Number.isFinite(direct)) return direct; const marketYes = getMarketYesPrice(scan); if (marketYes != null) return Math.max(0, Math.min(1, 1 - marketYes)); return null; } function normalizeMarketProbability(value?: number | null) { const numeric = Number(value); if (!Number.isFinite(numeric)) return null; if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100)); return Math.max(0, Math.min(1, numeric)); } function getMarketTopBuckets(scan?: MarketScan | null) { const buckets = Array.isArray(scan?.top_buckets) ? scan.top_buckets : []; if (!buckets.length) return []; return buckets .map((item) => ({ ...item, probability: normalizeMarketProbability(item.probability), })) .filter( (item): item is MarketTopBucket & { probability: number } => item.probability != null, ); } export function HeroSummary() { const { data } = useCityData(); const { locale } = useI18n(); if (!data) return null; const { weatherIcon, weatherText } = getWeatherSummary(data, locale); const metaItems = getHeroMetaItems(data, locale); const current = data.current || {}; const isMax = current.max_so_far != null && current.temp != null && current.max_so_far <= current.temp; return (
{weatherIcon} {weatherText}
{current.temp != null ? current.temp.toFixed(1) : "--"} {data.temp_symbol || "°C"}
{isMax && current.max_temp_time ? locale === "en-US" ? `Today's peak temperature appeared at local time ${current.max_temp_time}` : `该城市今日最高温出现在当地时间 ${current.max_temp_time}` : ""}
{locale === "en-US" ? "Current Obs" : "当前实测"} {current.temp != null ? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}` : "--"}
{locale === "en-US" ? "WU Settlement Ref" : "WU 结算参考"} {current.wu_settlement != null ? `${current.wu_settlement}${data.temp_symbol}` : "--"}
{locale === "en-US" ? "DEB Forecast" : "DEB 预测"} {data.deb?.prediction != null ? `${data.deb.prediction}${data.temp_symbol}` : "--"}
{metaItems.map((item) => ( {item} ))}
); } export function TemperatureChart() { const { data } = useCityData(); const { locale, t } = useI18n(); const chartData = data ? getTemperatureChartData(data, locale) : null; const canvasRef = useChart(() => { if (!data || !chartData) { return { data: { datasets: [], labels: [] }, type: "line", } satisfies ChartConfiguration<"line">; } const datasets: NonNullable< ChartConfiguration<"line">["data"] >["datasets"] = []; if (chartData.datasets.hasMgmHourly) { datasets.push({ backgroundColor: "rgba(234, 179, 8, 0.05)", borderColor: "rgba(234, 179, 8, 0.8)", borderWidth: 2, data: chartData.datasets.mgmHourlyPoints, fill: false, label: locale === "en-US" ? "MGM Forecast" : "MGM 预报", pointHoverRadius: 6, pointRadius: 3, spanGaps: true, tension: 0.3, }); } else { datasets.push({ backgroundColor: "rgba(52, 211, 153, 0.05)", borderColor: "rgba(52, 211, 153, 0.6)", borderWidth: 1.5, data: chartData.datasets.debPast, fill: true, label: locale === "en-US" ? "DEB Forecast" : "DEB 预报", pointHoverRadius: 3, pointRadius: 0, tension: 0.3, }); datasets.push({ borderColor: "rgba(52, 211, 153, 0.35)", borderDash: [5, 3], borderWidth: 1.5, data: chartData.datasets.debFuture, fill: false, label: locale === "en-US" ? "DEB Forecast" : "DEB 预报", pointRadius: 0, tension: 0.3, }); } datasets.push({ backgroundColor: "#22d3ee", borderColor: "#22d3ee", borderWidth: 0, data: chartData.datasets.metarPoints, fill: false, label: locale === "en-US" ? "METAR Observation" : "METAR 实测", order: 0, pointHoverRadius: 7, pointRadius: 5, }); if (chartData.datasets.mgmPoints.some((value) => value != null)) { datasets.push({ backgroundColor: "#facc15", borderColor: "#facc15", borderWidth: 0, data: chartData.datasets.mgmPoints, fill: false, label: locale === "en-US" ? "MGM Observation" : "MGM 实测", order: -1, pointHoverRadius: 9, pointRadius: 7, showLine: false, }); } if ( !chartData.datasets.hasMgmHourly && Math.abs(chartData.datasets.offset) > 0.3 ) { datasets.push({ borderColor: "rgba(99, 102, 241, 0.2)", borderDash: [2, 4], borderWidth: 1, data: chartData.datasets.temps, fill: false, label: locale === "en-US" ? "OM Raw" : "OM 原始", pointRadius: 0, tension: 0.3, }); } return { data: { datasets, labels: chartData.times, }, options: { interaction: { intersect: false, mode: "index" }, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: "rgba(15, 23, 42, 0.9)", borderColor: "rgba(52, 211, 153, 0.3)", borderWidth: 1, }, }, responsive: true, scales: { x: { grid: { color: "rgba(255,255,255,0.04)" }, ticks: { callback: (_value, index) => typeof index === "number" && index % 3 === 0 ? chartData.times[index] : "", color: "#64748b", maxRotation: 0, }, }, y: { grid: { color: "rgba(255,255,255,0.04)" }, max: chartData.max, min: chartData.min, ticks: { callback: (value) => `${value}${data.temp_symbol || "°C"}`, color: "#64748b", }, }, }, }, type: "line", } satisfies ChartConfiguration<"line">; }, [data, chartData, locale]); return (

{t("section.todayTempTrend")}

{chartData?.legendText || t("section.chartEmpty")}
); } export function ProbabilityDistribution({ detail, hideTitle = false, targetDate, marketScan, }: { detail: CityDetail; hideTitle?: boolean; targetDate?: string | null; marketScan?: MarketScan | null; }) { const { locale, t } = useI18n(); const view = getProbabilityView(detail, targetDate); const marketBucketTemp = getMarketBucketTemp(marketScan); const marketYesPrice = getMarketYesPrice(marketScan); const marketNoPrice = getMarketNoPrice(marketScan); const marketYesText = toPercent(marketYesPrice); const marketNoText = toPercent(marketNoPrice); const isToday = !targetDate || targetDate === detail.local_date; const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : []; const sortedMarketTopBuckets = [...marketTopBuckets] .sort((a, b) => Number(b.probability || 0) - Number(a.probability || 0)) .slice(0, 4); const useMarketTopBuckets = marketScan?.available && sortedMarketTopBuckets.length > 0; const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability); return (
{!hideTitle &&

{t("section.probability")}

}
{view.mu != null && (
{t("section.mu", { unit: detail.temp_symbol || "", value: view.mu.toFixed(1), })}
)} {marketScan?.available && (topMarketBucketText || marketYesText) && (
{useMarketTopBuckets ? locale === "en-US" ? `Market top-4 buckets (top): ${topMarketBucketText}` : `市场概率(前4温度桶):最高 ${topMarketBucketText}` : locale === "en-US" ? `Market probability (this bucket): ${marketYesText}` : `市场概率(该温度桶): ${marketYesText}`}
)} {useMarketTopBuckets ? ( sortedMarketTopBuckets.map((bucket, index) => { const probability = Math.round( Number(bucket.probability || 0) * 100, ); let bucketLabel = bucket.label || (bucket.value != null ? `${bucket.value}${detail.temp_symbol}` : `${bucket.temp ?? "--"}${detail.temp_symbol}`); if (bucketLabel) { let str = String(bucketLabel).toUpperCase().replace(/\s+/g, ""); str = str.replace(/°?C($|\+|-)/g, "℃$1"); if (!str.includes("℃") && /[0-9]/.test(str)) { str += "℃"; } bucketLabel = str; } const buyYesText = toPriceCents( bucket.yes_buy ?? bucket.market_price ?? bucket.probability, ); const buyNoText = toPriceCents(bucket.no_buy); const marketTag = buyYesText ? locale === "en-US" ? `Buy Yes: ${buyYesText}` : `买 Yes: ${buyYesText}` : buyNoText ? locale === "en-US" ? `Buy No: ${buyNoText}` : `买 No: ${buyNoText}` : null; return (
{bucketLabel}
{probability}%
{marketTag && (
{marketTag}
)}
); }) ) : view.probabilities.length === 0 ? ( ) : ( view.probabilities.slice(0, 6).map((bucket, index) => { const probability = Math.round( Number(bucket.probability || 0) * 100, ); const bucketTemp = getBucketTemp(bucket); const isMarketBucket = marketYesText != null && marketBucketTemp != null && bucketTemp != null && Math.abs(bucketTemp - marketBucketTemp) < 0.26; const marketTag = isMarketBucket ? locale === "en-US" ? `Buy Yes: ${marketYesText || "--"}` : `买 Yes: ${marketYesText || "--"}` : marketNoText ? locale === "en-US" ? `Buy No: ${marketNoText}` : `买 No: ${marketNoText}` : null; const yesPriceText = toPriceCents(marketYesPrice); const noPriceText = toPriceCents(marketNoPrice); const marketTagFinal = isMarketBucket ? locale === "en-US" ? `Buy Yes: ${yesPriceText || "--"}` : `买 Yes: ${yesPriceText || "--"}` : noPriceText ? locale === "en-US" ? `Buy No: ${noPriceText}` : `买 No: ${noPriceText}` : marketTag; let bucketLabel = bucket.label || `${bucket.value}${detail.temp_symbol}`; if (bucketLabel) { let str = String(bucketLabel).toUpperCase().replace(/\s+/g, ""); str = str.replace(/°?C($|\+|-)/g, "℃$1"); if (!str.includes("℃") && /[0-9]/.test(str)) { str += "℃"; } bucketLabel = str; } return (
{bucketLabel}
{probability}%
{marketTagFinal && (
{marketTagFinal}
)}
); }) )}
); } export function ModelForecast({ detail, hideTitle = false, targetDate, }: { detail: CityDetail; hideTitle?: boolean; targetDate?: string | null; }) { const { locale, t } = useI18n(); const view = getModelView(detail, targetDate); const fallbackMeteoblue = detail.source_forecasts?.meteoblue?.today_high; const modelsMap = { ...view.models }; // 强行插入 Meteoblue,防止 helper 函数或日期对齐导致其被剔除 if ( modelsMap["Meteoblue"] == null && fallbackMeteoblue != null && (!targetDate || targetDate === detail.local_date) ) { modelsMap["Meteoblue"] = fallbackMeteoblue; } const modelEntries = Object.entries(modelsMap).filter( ([, value]) => value !== null && value !== undefined && Number.isFinite(Number(value)), ); // 如果没有任何数值,给出提示 if (modelEntries.length === 0) { return (
{!hideTitle &&

{t("section.models")}

}
); } const numericValues = modelEntries.map(([, value]) => Number(value)); const comparisonValues = view.deb != null ? [...numericValues, Number(view.deb)] : numericValues; const minValue = comparisonValues.length ? Math.min(...comparisonValues) - 1 : 0; const maxValue = comparisonValues.length ? Math.max(...comparisonValues) + 1 : 1; const range = Math.max(maxValue - minValue, 1); const getModelDisplayName = (name: string) => { if (String(name).trim().toLowerCase() === "meteoblue") { return "Meteoblue"; } return name; }; return (
{!hideTitle &&

{t("section.models")}

}
{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; return (
{getModelDisplayName(name)}
{numeric} {detail.temp_symbol}
{debLine != null && (
)}
); })} {view.deb != null && (
DEB
{Number(view.deb)} {detail.temp_symbol}
)}
); } export function ForecastTable() { const store = useDashboardStore(); const { data } = useCityData(); const { t } = useI18n(); if (!data) return null; const daily = data.forecast?.daily || []; return (

{t("forecast.title")}

{daily.length === 0 ? ( ) : ( daily.map((day, index) => { const isToday = day.date === data.local_date || index === 0; const isSelected = store.futureModalDate === day.date || store.selectedForecastDate === day.date; return ( ); }) )}
); } export function AiAnalysis() { const { data } = useCityData(); const { t } = useI18n(); if (!data) return null; const ai = parseAiAnalysis(data.ai_analysis); return (

{t("section.ai")}

{!ai.summary && ai.bullets.length === 0 ? ( {t("section.aiEmpty")} ) : ( <> {ai.summary &&
{ai.summary}
} {ai.bullets.length > 0 && ( )} )}
); } export function RiskInfo() { const { data } = useCityData(); const { t } = useI18n(); if (!data) return null; const risk = data.risk || {}; return (

{t("section.risk")}

{!risk.airport ? ( {t("section.noRiskProfile")} ) : ( <>
{t("section.airport")} {risk.airport} ({risk.icao})
{t("section.distance")} {risk.distance_km}km
{risk.warning && (
{t("section.note")} {risk.warning}
)} )}
); }