"use client"; import type { ChartConfiguration } from "chart.js"; import clsx from "clsx"; import { startTransition, useMemo } from "react"; 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, } from "@/lib/dashboard-utils"; function EmptyState({ text }: { text: string }) { return (
{text}
); } function toPercent(value?: number | null) { if (value == null) return null; const numeric = Number(value); if (!Number.isFinite(numeric)) return null; return `${(numeric * 100).toFixed(1)}%`; } function toPriceCents(value?: number | null) { if (value == null) return 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) { if (bucket.value != null) { const byValue = Number(bucket.value); if (Number.isFinite(byValue)) return byValue; } return parseTempFromText(bucket.label || bucket.bucket || bucket.range); } function getMarketYesPrice(scan?: MarketScan | null) { if (scan?.market_price != null) { const preferred = Number(scan.market_price); if (Number.isFinite(preferred)) return preferred; } if (scan?.yes_token?.implied_probability != null) { const implied = Number(scan.yes_token.implied_probability); if (Number.isFinite(implied)) return implied; } return null; } function isFahrenheitSymbol(symbol?: string | null) { return String(symbol || "").toUpperCase().includes("F"); } function displayTempToMarketCelsius( value: number | null, detail: Pick, ) { if (value == null || !Number.isFinite(value)) return null; if (isFahrenheitSymbol(detail.temp_symbol)) { return ((value - 32) * 5) / 9; } return value; } function formatBucketDisplayLabel( bucket: ProbabilityBucket, detail: Pick, ) { let bucketLabel = bucket.label || `${bucket.value}${detail.temp_symbol}`; if (!bucketLabel) return ""; let str = String(bucketLabel).toUpperCase().replace(/\s+/g, ""); const symbol = detail.temp_symbol || "°C"; if (isFahrenheitSymbol(symbol)) { str = str.replace(/℃/g, "°F").replace(/°C/g, "°F"); } else { str = str.replace(/℃/g, "°C").replace(/°F/g, "°C"); } str = str.replace(/°?C($|\+|-)/g, "°C$1"); str = str.replace(/°?F($|\+|-)/g, "°F$1"); if (!/[°℃][CF]/.test(str) && /[0-9]/.test(str)) { str += symbol; } return str; } function getMarketBucketUnit(bucket?: MarketTopBucket | null) { return String(bucket?.unit || "").toUpperCase(); } function isMarketBucketAbove(bucket?: MarketTopBucket | null) { const text = `${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}` .toLowerCase() .replace(/\s+/g, ""); return text.includes("+") || text.includes("orhigher") || text.includes("or-higher"); } function isMarketBucketBelow(bucket?: MarketTopBucket | null) { const text = `${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}` .toLowerCase() .replace(/\s+/g, ""); return text.includes("<=") || text.includes("orlower") || text.includes("or-lower"); } function findMarketBucketForDisplayTemp( buckets: MarketTopBucket[], displayTemp: number | null, detail: Pick, ) { if (displayTemp == null || !Number.isFinite(displayTemp)) return null; let best: MarketTopBucket | null = null; let bestDelta = Number.POSITIVE_INFINITY; for (const bucket of buckets) { const bucketUnit = String(bucket.unit || "").toUpperCase(); const compareTemp = bucketUnit === "F" ? displayTemp : displayTempToMarketCelsius(displayTemp, detail); if (compareTemp == null) continue; const lower = bucket.lower != null ? Number(bucket.lower) : null; const upper = bucket.upper != null ? Number(bucket.upper) : null; if ( lower != null && upper != null && Number.isFinite(lower) && Number.isFinite(upper) && compareTemp >= lower - 0.01 && compareTemp <= upper + 0.01 ) { return bucket; } const rawTemp = bucket.temp ?? bucket.value ?? null; if (rawTemp == null) continue; const candidateTemp = Number(rawTemp); if (!Number.isFinite(candidateTemp)) continue; const delta = Math.abs(candidateTemp - compareTemp); if (delta < bestDelta) { best = bucket; bestDelta = delta; } } const tolerance = isFahrenheitSymbol(detail.temp_symbol) ? 0.56 : 0.26; return best && bestDelta <= tolerance ? best : null; } function marketBucketContainsDisplayTemp( bucket: MarketTopBucket | null, displayTemp: number | null, detail: Pick, ) { if (!bucket || displayTemp == null || !Number.isFinite(displayTemp)) return false; const bucketUnit = getMarketBucketUnit(bucket); const compareTemp = bucketUnit === "F" ? displayTemp : displayTempToMarketCelsius(displayTemp, detail); if (compareTemp == null) return false; const lower = bucket.lower != null ? Number(bucket.lower) : null; const upper = bucket.upper != null ? Number(bucket.upper) : null; if (lower != null && !Number.isFinite(lower)) return false; if (upper != null && !Number.isFinite(upper)) return false; if (lower != null && upper != null) { return compareTemp >= lower - 0.01 && compareTemp <= upper + 0.01; } if (lower != null && isMarketBucketAbove(bucket)) { return compareTemp >= lower - 0.01; } if (lower != null && isMarketBucketBelow(bucket)) { return compareTemp <= lower + 0.01; } const reference = bucket.temp ?? bucket.value ?? lower; const numeric = reference != null ? Number(reference) : null; if (numeric == null || !Number.isFinite(numeric)) return false; const tolerance = bucketUnit === "F" ? 0.56 : 0.26; return Math.abs(compareTemp - numeric) <= tolerance; } function getAggregatedModelProbabilityForMarketBucket( probabilities: ProbabilityBucket[], bucket: MarketTopBucket | null, detail: Pick, ) { if (!bucket) return null; let total = 0; let matched = 0; for (const probabilityBucket of probabilities) { const temp = getBucketTemp(probabilityBucket); if (!marketBucketContainsDisplayTemp(bucket, temp, detail)) continue; const probability = Number(probabilityBucket.probability); if (!Number.isFinite(probability)) continue; total += probability; matched += 1; } return matched > 0 ? Math.max(0, Math.min(1, total)) : null; } type ProbabilityDisplayRow = { key: string; label: string; probability: number; marketBucket?: MarketTopBucket | null; }; function formatMarketBucketDisplayLabel( bucket: MarketTopBucket, detail: Pick, ) { const label = String(bucket.label || "").trim(); if (label) { const unit = getMarketBucketUnit(bucket); let normalized = label.toUpperCase().replace(/\s+/g, ""); if (unit === "F" || isFahrenheitSymbol(detail.temp_symbol)) { normalized = normalized .replace(/ORHIGHER/g, "+") .replace(/ORLOWER/g, "-") .replace(/℃/g, "°F") .replace(/°C/g, "°F") .replace(/(?<=\d)F/g, "°F"); } else { normalized = normalized .replace(/ORHIGHER/g, "+") .replace(/ORLOWER/g, "-") .replace(/℃/g, "°C") .replace(/°F/g, "°C") .replace(/(?<=\d)C/g, "°C"); } return normalized.replace(/\+/g, "+"); } const unit = getMarketBucketUnit(bucket) === "F" || isFahrenheitSymbol(detail.temp_symbol) ? "°F" : "°C"; const lower = bucket.lower != null ? Number(bucket.lower) : null; const upper = bucket.upper != null ? Number(bucket.upper) : null; if (lower != null && upper != null && Number.isFinite(lower) && Number.isFinite(upper)) { return `${lower}-${upper}${unit}`; } const value = bucket.value ?? bucket.temp ?? lower; const numeric = value != null ? Number(value) : null; if (numeric != null && Number.isFinite(numeric)) { return isMarketBucketAbove(bucket) ? `${numeric}${unit}+` : `${numeric}${unit}`; } return "--"; } type ModelMetadata = NonNullable< NonNullable["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("aifs") || upperName.includes("AIFS")) { return { key: "aifs", label: locale === "en-US" ? "AIFS model" : "AIFS 模型", 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 normalizeModelNameForVote(name: string) { return String(name || "") .trim() .toLowerCase() .replace(/[\s_/-]/g, ""); } function getModelVoteFamily(name: string) { const normalized = normalizeModelNameForVote(name); if (["icon", "iconeu", "icond2"].includes(normalized)) return "dwd_icon"; if (["gem", "gdps", "rdps", "hrdps"].includes(normalized)) return "eccc_gem"; if (["ecmwfaifs", "aifs"].includes(normalized)) return "ecmwf_aifs"; if (normalized === "ecmwf") return "ecmwf_ifs"; return normalized || name; } function getModelVotePriority(name: string) { const normalized = normalizeModelNameForVote(name); return ( { icond2: 40, iconeu: 30, icon: 20, hrdps: 40, rdps: 35, gdps: 30, gem: 20, ecmwfaifs: 30, ecmwf: 30, gfs: 30, jma: 30, mgm: 45, nws: 45, openmeteo: 15, }[normalized] || 10 ); } function getRoundedModelVoteDistribution( detail: CityDetail, targetDate?: string | null, ) { const view = getModelView(detail, targetDate); const representatives = new Map< string, { name: string; priority: number; value: number } >(); Object.entries(view.models || {}).forEach(([name, rawValue]) => { const normalized = normalizeModelNameForVote(name); if (normalized === "lgbm" || normalized.includes("meteoblue")) return; const value = Number(rawValue); if (!Number.isFinite(value)) return; const family = getModelVoteFamily(name); const priority = getModelVotePriority(name); const current = representatives.get(family); if (!current || priority > current.priority) { representatives.set(family, { name, priority, value }); } }); const bucketMap = new Map(); representatives.forEach(({ name, value }) => { const rounded = Math.round(value); const row = bucketMap.get(rounded) || { count: 0, models: [] }; row.count += 1; row.models.push(name); bucketMap.set(rounded, row); }); const total = representatives.size; const rows = Array.from(bucketMap.entries()) .map(([value, row]) => ({ count: row.count, models: row.models, percent: total > 0 ? row.count / total : 0, value, })) .sort((a, b) => b.count - a.count || b.value - a.value); return { rows, total, }; } function normalizeMarketProbability(value?: number | null) { if (value == null) return 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 normalizeSignedProbability(value?: number | null) { if (value == null) return null; const numeric = Number(value); if (!Number.isFinite(numeric)) return null; if (Math.abs(numeric) > 1) return numeric / 100; return numeric; } function formatSignedPercent(value?: number | null, digits = 1) { const normalized = normalizeSignedProbability(value); if (normalized == null) return "--"; const percent = normalized * 100; const sign = percent > 0 ? "+" : ""; return `${sign}${percent.toFixed(digits)}%`; } 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, ); } function getMarketAllBuckets(scan?: MarketScan | null) { const buckets = Array.isArray(scan?.all_buckets) ? scan.all_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, ); } function getMarketTopBucketKey(bucket: MarketTopBucket) { if (bucket?.value != null) { const valueNum = Number(bucket.value); if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`; } if (bucket?.temp != null) { const tempNum = Number(bucket.temp); if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`; } const parsed = parseTempFromText(bucket?.label); if (parsed != null) return `l:${parsed.toFixed(2)}`; return `s:${String(bucket?.slug || bucket?.question || bucket?.label || "")}`; } function hasLgbmModel(detail: CityDetail, targetDate?: string | null) { const view = getModelView(detail, targetDate); return Object.keys(view.models || {}).some((name) => normalizeModelNameForVote(name).includes("lgbm"), ); } function formatProbabilityEngineLabel( detail: CityDetail, targetDate: string | null | undefined, locale: string, ) { const view = getProbabilityView(detail, targetDate); if (hasLgbmModel(detail, targetDate)) { return locale === "en-US" ? "LGBM-calibrated probability" : "LGBM 校准概率"; } const engine = String(view.engine || "").trim().toLowerCase(); const calibrationMode = String(view.calibrationMode || "") .trim() .toLowerCase(); if (engine === "emos" || calibrationMode.includes("emos")) { return locale === "en-US" ? "EMOS-calibrated probability" : "EMOS 校准概率"; } return locale === "en-US" ? "Model probability" : "模型概率"; } 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 settlementSourceCode = String(current.settlement_source || "metar") .trim() .toLowerCase(); const settlementIcao = String( current.station_code || data.risk?.icao || "", ) .trim() .toUpperCase(); const settlementSource = settlementSourceCode === "wunderground" ? settlementIcao ? `${settlementIcao} METAR` : "METAR" : String(current.settlement_source_label || current.settlement_source || "METAR") .trim() .toUpperCase(); const isMax = current.max_so_far != null && current.temp != null && current.max_so_far <= current.temp; const currentObsText = current.temp != null ? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}` : data.metar_status?.stale_for_today ? locale === "en-US" ? "No same-day METAR" : "今日暂无 METAR" : "--"; 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" : "当前实测"} {currentObsText}
{locale === "en-US" ? `${settlementSource} Anchor` : `${settlementSource} 锚点`} {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 = useMemo( () => (data ? getTemperatureChartData(data, locale) : null), [data, locale], ); 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: chartData.observationLabel || (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 marketYesPrice = getMarketYesPrice(marketScan); const marketYesText = toPercent(marketYesPrice); const isToday = !targetDate || targetDate === detail.local_date; const probabilityEngineLabel = formatProbabilityEngineLabel( detail, targetDate, locale, ); const hasLgbmProbability = hasLgbmModel(detail, targetDate); const modelVoteView = useMemo( () => getRoundedModelVoteDistribution(detail, targetDate), [detail, targetDate], ); const modelVoteHint = modelVoteView.rows .slice(0, 2) .map( (row) => `${row.value}${detail.temp_symbol} ${row.count}/${modelVoteView.total}`, ) .join(" · "); const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : []; const marketAllBuckets = isToday ? getMarketAllBuckets(marketScan) : []; const sortedMarketTopBuckets = useMemo(() => { const sorted = [...marketTopBuckets].sort( (a, b) => Number(b.probability || 0) - Number(a.probability || 0), ); const deduped: Array = []; const seenKeys = new Set(); for (const row of sorted) { const key = getMarketTopBucketKey(row); if (seenKeys.has(key)) continue; seenKeys.add(key); deduped.push(row); if (deduped.length >= 4) break; } return deduped; }, [marketTopBuckets]); const useMarketTopBuckets = marketScan?.available && sortedMarketTopBuckets.length >= 2; const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability); const topProbability = [...(view.probabilities || [])].sort( (a, b) => Number(b.probability || 0) - Number(a.probability || 0), )[0]; const topProbabilityText = toPercent(topProbability?.probability); const topProbabilityLabel = topProbability ? formatBucketDisplayLabel(topProbability, detail) : null; const topProbabilityTemp = topProbability ? getBucketTemp(topProbability) : null; const probabilitiesForMarketContracts = view.probabilitiesAll?.length > 0 ? view.probabilitiesAll : view.probabilities || []; const marketContractRows = useMemo(() => { if (!isToday || !marketScan?.available || marketAllBuckets.length === 0) { return []; } const rows: ProbabilityDisplayRow[] = []; const seenKeys = new Set(); for (const marketBucket of marketAllBuckets) { const probability = getAggregatedModelProbabilityForMarketBucket( probabilitiesForMarketContracts, marketBucket, detail, ); const key = marketBucket.slug || marketBucket.label || `${marketBucket.lower ?? marketBucket.value ?? marketBucket.temp}-${marketBucket.upper ?? ""}`; if (seenKeys.has(key)) continue; seenKeys.add(key); rows.push({ key, label: formatMarketBucketDisplayLabel(marketBucket, detail), probability: probability ?? 0, marketBucket, }); } return rows; }, [ detail, isToday, marketAllBuckets, marketScan?.available, probabilitiesForMarketContracts, ]); const modelProbabilityRows = useMemo( () => (view.probabilities || []).slice(0, 6).map((bucket, index) => { const bucketTemp = getBucketTemp(bucket); return { key: `${bucket.label || bucket.value || index}`, label: formatBucketDisplayLabel(bucket, detail), probability: Number(bucket.probability || 0), marketBucket: findMarketBucketForDisplayTemp( marketAllBuckets, bucketTemp, detail, ), }; }), [detail, marketAllBuckets, view.probabilities], ); const probabilityRows = marketContractRows.length > 0 ? marketContractRows.slice(0, 8) : modelProbabilityRows; const topContractRow = marketContractRows.length > 0 ? marketContractRows.reduce((best, row) => row.probability > best.probability ? row : best, ) : null; const displayTopLabel = topContractRow?.label || topProbabilityLabel || null; const displayTopProbability = topContractRow?.probability ?? (topProbability?.probability != null ? Number(topProbability.probability) : null); const displayTopProbabilityText = toPercent(displayTopProbability); const displayUsesMarketBuckets = marketContractRows.length > 0; const linkedMarketBucket = useMemo(() => { if (topContractRow?.marketBucket) return topContractRow.marketBucket; if (topProbabilityTemp == null) return null; return findMarketBucketForDisplayTemp( marketAllBuckets, topProbabilityTemp, detail, ); }, [detail, marketAllBuckets, topContractRow, topProbabilityTemp]); const priceAnalysis = marketScan?.price_analysis; const yesPriceView = priceAnalysis?.yes; const noPriceView = priceAnalysis?.no; const linkedMarketAsk = linkedMarketBucket?.yes_buy ?? linkedMarketBucket?.market_price ?? yesPriceView?.ask ?? null; const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null; const linkedContractLabel = topContractRow?.label || (linkedMarketBucket ? formatMarketBucketDisplayLabel(linkedMarketBucket, detail) : null) || topProbabilityLabel || null; const aggregatedMarketProbability = getAggregatedModelProbabilityForMarketBucket( probabilitiesForMarketContracts, linkedMarketBucket, detail, ); const linkedMarketProbability = topContractRow?.probability ?? aggregatedMarketProbability ?? (topProbability?.probability != null ? Number(topProbability.probability) : null); const linkedMarketProbabilityText = toPercent(linkedMarketProbability); const linkedMarketEdge = linkedMarketProbability != null && linkedMarketAsk != null ? linkedMarketProbability - Number(linkedMarketAsk) : null; const linkedNoEdge = linkedMarketProbability != null && linkedNoAsk != null ? 1 - linkedMarketProbability - Number(linkedNoAsk) : null; const linkedBestSide = linkedMarketBucket && linkedNoEdge != null && linkedMarketEdge != null ? linkedNoEdge > linkedMarketEdge ? "no" : "yes" : null; const linkedBestAsk = linkedBestSide === "no" ? linkedNoAsk : linkedMarketAsk; const linkedBestEdge = linkedBestSide === "no" ? linkedNoEdge : linkedMarketEdge; const preferredPriceView = linkedMarketBucket ? { ask: linkedBestAsk, edge: linkedBestEdge, } : priceAnalysis?.best_side === "no" ? noPriceView : yesPriceView; const preferredSideLabel = linkedMarketBucket ? linkedBestSide === "no" ? "NO" : "YES" : priceAnalysis?.best_side === "no" ? locale === "en-US" ? "NO" : "NO" : locale === "en-US" ? "YES" : "YES"; const yesDisplayPrice = linkedMarketBucket ? linkedMarketAsk : yesPriceView?.ask; const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask; const yesDisplayEdge = linkedMarketBucket ? linkedMarketEdge : yesPriceView?.edge; const noDisplayEdge = linkedMarketBucket ? linkedNoEdge : noPriceView?.edge; const hasPriceAnalysis = isToday && (Boolean(priceAnalysis?.available) || Boolean(marketScan) || Boolean(topProbability)); const lockEdge = normalizeSignedProbability(priceAnalysis?.lock?.edge); const lockAvailable = Boolean(priceAnalysis?.lock?.available && lockEdge != null); const quoteSource = linkedMarketBucket?.quote_source || marketScan?.yes_token?.quote_source || marketScan?.no_token?.quote_source || null; const quoteAgeMs = linkedMarketBucket?.quote_age_ms ?? marketScan?.yes_token?.quote_age_ms ?? marketScan?.no_token?.quote_age_ms; const quoteSourceLabel = quoteSource === "polymarket_ws" ? locale === "en-US" ? `WS live${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}s` : ""}` : `WS 实时${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}秒` : ""}` : locale === "en-US" ? "CLOB fallback" : "CLOB 兜底"; const actionableEdge = normalizeSignedProbability(preferredPriceView?.edge); const linkedContractOverpriced = Boolean(linkedMarketBucket) && linkedBestSide === "no" && linkedMarketProbability != null && linkedMarketAsk != null && linkedMarketEdge != null && linkedMarketEdge < 0 && linkedNoEdge != null && linkedNoEdge > 0; const linkedContractOverpay = linkedContractOverpriced && linkedMarketProbability != null && linkedMarketAsk != null ? Number(linkedMarketAsk) - linkedMarketProbability : null; const actionText = !marketScan ? locale === "en-US" ? "Waiting" : "等待" : !marketScan.available ? locale === "en-US" ? "No market" : "无盘口" : actionableEdge == null ? locale === "en-US" ? "No quote" : "无报价" : actionableEdge >= 0.02 ? linkedContractOverpriced ? locale === "en-US" ? "Overpriced" : "市场偏贵" : locale === "en-US" ? `Watch ${preferredSideLabel}` : `可关注 ${preferredSideLabel}` : actionableEdge > 0 ? linkedContractOverpriced ? locale === "en-US" ? "Slightly overpriced" : "略偏贵" : locale === "en-US" ? `Small ${preferredSideLabel}` : `${preferredSideLabel} 优势较小` : locale === "en-US" ? "No clear edge" : "暂无优势"; const actionNote = linkedContractOverpriced && linkedContractOverpay != null ? locale === "en-US" ? `YES above model by ${formatSignedPercent(linkedContractOverpay)}` : `YES 高于模型 ${formatSignedPercent(linkedContractOverpay)}` : actionableEdge != null && actionableEdge >= 0.02 ? locale === "en-US" ? `${formatSignedPercent(actionableEdge)} vs ask` : `相对买价 ${formatSignedPercent(actionableEdge)}` : locale === "en-US" ? `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}` : `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`; return (
{!hideTitle &&

{t("section.probability")}

}
{probabilityEngineLabel} {displayTopLabel && displayTopProbabilityText ? locale === "en-US" ? displayUsesMarketBuckets ? `${displayTopLabel} is the top displayed contract bucket at ${displayTopProbabilityText}` : `${displayTopLabel} is the top single bucket at ${displayTopProbabilityText}` : displayUsesMarketBuckets ? `${displayTopLabel} 为当前显示分布最高,${displayTopProbabilityText}` : `${displayTopLabel} 单点最高,${displayTopProbabilityText}` : locale === "en-US" ? "Awaiting calibrated buckets" : "等待校准概率桶"}

{hasLgbmProbability ? locale === "en-US" ? "LGBM is the learned intraday adjustment; raw model points below are only diagnostic." : "LGBM 作为日内学习校准项;下方原始模型落点仅用于诊断。" : locale === "en-US" ? "Using the calibrated probability distribution; raw model points below are not probabilities." : "使用校准后的概率分布;下方原始模型落点不是概率。"}

{marketScan?.available && (topMarketBucketText || marketYesText) && (
{useMarketTopBuckets ? locale === "en-US" ? `Market reference only: top traded bucket ${topMarketBucketText}` : `市场仅作参考:最高交易温度桶 ${topMarketBucketText}` : locale === "en-US" ? `Market reference only: this bucket ${marketYesText}` : `市场仅作参考:该温度桶 ${marketYesText}`}
)}
{locale === "en-US" ? "EMOS probability distribution" : "EMOS 概率分布"} {marketContractRows.length > 0 ? locale === "en-US" ? "market buckets are aggregated from single-degree EMOS buckets" : "市场合约桶由单点 EMOS 概率聚合" : locale === "en-US" ? "calibrated temperature buckets" : "校准后的温度桶"}
{probabilityRows.length === 0 ? ( ) : ( probabilityRows.map((row, index) => { const probability = Math.round(Number(row.probability || 0) * 100); const rowMarketBucket = row.marketBucket; const rowMarketPrice = rowMarketBucket?.yes_buy ?? rowMarketBucket?.market_price ?? null; const yesPriceText = toPriceCents(rowMarketPrice); const marketTagFinal = rowMarketBucket ? locale === "en-US" ? `YES ask: ${yesPriceText || "--"}` : `YES 买价: ${yesPriceText || "--"}` : null; return (
{row.label}
{probability}%
{marketTagFinal && (
{marketTagFinal}
)}
); }) )}
{hasPriceAnalysis && (
{locale === "en-US" ? "Probability x Price" : "概率 x 价格联动"} {!marketScan ? locale === "en-US" ? "Waiting for market layer" : "等待市场层" : !marketScan.available ? locale === "en-US" ? "No matched active market" : "未匹配到活跃盘口" : linkedMarketBucket ? locale === "en-US" ? `${actionText}: ${linkedContractLabel || "contract bucket"}` : `${actionText}:${linkedContractLabel || "合约桶"}` : preferredPriceView?.edge != null ? locale === "en-US" ? `${actionText} · edge ${formatSignedPercent(preferredPriceView.edge)}` : `${actionText} · 优势 ${formatSignedPercent(preferredPriceView.edge)}` : locale === "en-US" ? "Waiting for executable quote" : "等待可执行报价"}
{locale === "en-US" ? "Contract bucket" : "合约桶口径"} {linkedContractLabel || topProbabilityLabel || "--"} {linkedMarketProbabilityText || topProbabilityText || "--"}
{locale === "en-US" ? "Candidate" : "可关注"} {actionText} {actionNote}
{locale === "en-US" ? "YES price" : "YES 价格"} {toPriceCents(yesDisplayPrice) || "--"} {locale === "en-US" ? `edge ${formatSignedPercent(yesDisplayEdge)}` : `优势 ${formatSignedPercent(yesDisplayEdge)}`}
{locale === "en-US" ? "NO price" : "NO 价格"} {toPriceCents(noDisplayPrice) || "--"} {locale === "en-US" ? `edge ${formatSignedPercent(noDisplayEdge)}` : `优势 ${formatSignedPercent(noDisplayEdge)}`}

{locale === "en-US" ? `Read-only comparison between model probability and executable ask; it does not place orders. Source: ${quoteSourceLabel}${lockAvailable ? ` · lock ${formatSignedPercent(lockEdge)}` : ""}.` : `只比较模型概率与可执行买价;系统不会下单。来源:${quoteSourceLabel}${lockAvailable ? ` · 锁价 ${formatSignedPercent(lockEdge)}` : ""}。`}

)} {modelVoteHint && (
{locale === "en-US" ? "Raw model points" : "原始模型落点"} {modelVoteHint} {locale === "en-US" ? "diagnostic only; EMOS and contract rows use calibrated probabilities" : "仅作诊断;EMOS 与合约行使用校准概率"}
)}
); } export function ModelForecast({ detail, hideTitle = false, targetDate, }: { detail: CityDetail; hideTitle?: boolean; targetDate?: string | null; }) { 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]) => value !== null && value !== undefined && Number.isFinite(Number(value)), ); const hasSingleModelOnly = modelEntries.length === 1; // 如果没有任何数值,给出提示 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 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 (
{!hideTitle &&

{t("section.models")}

}
{locale === "en-US" ? "Available models" : "可用模型"} ·{" "} {modelEntries.length} {locale === "en-US" ? "Spread" : "分歧"} ·{" "} {spread != null ? `${spread.toFixed(1)}${detail.temp_symbol}` : "--"} {metadataSource && ( {locale === "en-US" ? "API" : "接口"} ·{" "} {metadataSource} )}
{hasSingleModelOnly && (
{locale === "en-US" ? "Single-model fallback: waiting for the rest of the model cluster." : "当前处于单模型回退,其他模型结果还没回传。"}
)} {groupedEntries.map((group) => (
{group.label} {group.entries.length}
{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 (
{name} {metaLine && {metaLine}}
{value} {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 { locale, t } = useI18n(); if (!data) return null; const daily = data.forecast?.daily || []; const isSparseDaily = daily.length <= 1; const isForecastCompleting = store.loadingState.cityDetail && (data.detail_depth !== "full" || isSparseDaily); const resolveForecastTemp = (date: string, fallback: number | null | undefined) => { const debPrediction = data.multi_model_daily?.[date]?.deb?.prediction; return debPrediction ?? fallback ?? null; }; return (

{t("forecast.title")}

{isSparseDaily && (
{isForecastCompleting ? locale === "en-US" ? "Multi-day forecast is syncing. Only the current-day card has arrived." : "多日预报同步中,当前只到达当日卡片。" : locale === "en-US" ? "Only the current-day forecast is available right now." : "当前只收到当日预报,其他日期结果暂未回传。"}
)}
{daily.length === 0 ? ( ) : ( daily.map((day, index) => { const isToday = day.date === data.local_date || index === 0; const isSelected = (isToday && store.forecastModalMode === "today" && Boolean(store.futureModalDate)) || (store.forecastModalMode !== "today" && store.futureModalDate === day.date) || store.selectedForecastDate === day.date; return ( ); }).concat( isForecastCompleting ? Array.from({ length: Math.max(0, 5 - daily.length) }).map((_, index) => ( )) : [], ) )}
); } 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}
)} )}
); }