"use client"; import { BarChart3, ChevronDown, ChevronUp } from "lucide-react"; import React from "react"; import { useI18n } from "@/hooks/useI18n"; import type { CityDetail, ScanOpportunityRow, } from "@/lib/dashboard-types"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; import { formatTemperatureValue, getModelView, getProbabilityView, getTodayPaceView, normalizeTemperatureLabel, normalizeTemperatureSymbol, } from "@/lib/dashboard-utils"; type PhaseMeta = { label: string; tone: "green" | "amber" | "blue" | "red"; }; function formatPercent(value?: number | null, signed = false) { if (value == null || Number.isNaN(Number(value))) return "--"; const numeric = Number(value); return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`; } function normalizeProbability(value?: number | null) { if (value == null) return null; const numeric = Number(value); if (!Number.isFinite(numeric)) return null; return Math.max(0, Math.min(1, numeric > 1 ? numeric / 100 : numeric)); } function formatWindowMinutes(value: number | null | undefined, locale: string) { if (value == null || !Number.isFinite(Number(value))) return "--"; const minutes = Math.max(0, Math.round(Number(value))); const hours = Math.floor(minutes / 60); const remains = minutes % 60; if (locale === "en-US") { if (hours <= 0) return `${remains}m left`; return `${hours}h ${remains}m left`; } if (hours <= 0) return `剩余 ${remains} 分钟`; return `剩余 ${hours}h ${remains}m`; } function formatMinuteSpan(value: number | null | undefined, locale: string) { if (value == null || !Number.isFinite(Number(value))) return "--"; const minutes = Math.max(0, Math.round(Number(value))); const hours = Math.floor(minutes / 60); const remains = minutes % 60; if (locale === "en-US") { if (hours <= 0) return `${remains}m`; return `${hours}h ${remains}m`; } if (hours <= 0) return `${remains} 分钟`; return `${hours}h ${remains}m`; } function formatAction( row: ScanOpportunityRow, locale: string, tempSymbol?: string | null, ) { return formatTradeSide(row, locale, tempSymbol); } export function getWindowPhaseMeta( row: Pick, locale: string, ): PhaseMeta { const mode = String(row.window_phase || "").toLowerCase(); if (mode === "city_snapshot") { return { label: locale === "en-US" ? "City Snapshot" : "城市概况", tone: "blue", }; } if (mode === "active_peak") { return { label: locale === "en-US" ? "Peak Window" : "峰值窗口", tone: "red", }; } if (mode === "setup_today") { return { label: locale === "en-US" ? "Touch Play" : "触达博弈", tone: "red", }; } if (mode === "early_today") { return { label: locale === "en-US" ? "Early Today" : "日内早段", tone: "blue", }; } if (mode === "tomorrow" || mode === "week_ahead") { return { label: locale === "en-US" ? "Early" : "早期机会", tone: "blue", }; } if (mode === "post_peak") { return { label: locale === "en-US" ? "Post Peak" : "峰后确认", tone: "amber", }; } if (row.trend_alignment) { return { label: locale === "en-US" ? "Trend" : "趋势确认", tone: "amber", }; } return { label: locale === "en-US" ? "Tradable" : "可交易", tone: "green", }; } function formatQuoteCents(value?: number | null) { if (value == null || Number.isNaN(Number(value))) return "--"; const cents = Number(value) * 100; const text = cents < 1 || cents >= 99 || Math.abs(cents - Math.round(cents)) >= 0.05 ? cents.toFixed(1) : Math.round(cents).toFixed(0); return `${text.replace(/\.0$/, "")}¢`; } function formatTradeSide( row: ScanOpportunityRow, locale: string, tempSymbol?: string | null, ) { const side = String(row.side || "").toLowerCase(); const isEn = locale === "en-US"; const { lower, upper } = getTargetRange(row); const threshold = lower != null && upper == null ? formatTemperatureValue(lower, tempSymbol) : upper != null && lower == null ? formatTemperatureValue(upper, tempSymbol) : null; if (threshold && lower != null && upper == null) { if (side === "yes") return isEn ? `High reaches ${threshold}` : `最高温达到 ${threshold}`; if (side === "no") return isEn ? `High stays below ${threshold}` : `最高温低于 ${threshold}`; } if (threshold && upper != null && lower == null) { if (side === "yes") return isEn ? `High stays at/below ${threshold}` : `最高温不高于 ${threshold}`; if (side === "no") return isEn ? `High exceeds ${threshold}` : `最高温高于 ${threshold}`; } if (lower != null && upper != null && Math.abs(lower - upper) > 0.01) { const range = `${formatTemperatureValue(lower, tempSymbol)} ~ ${formatTemperatureValue(upper, tempSymbol)}`; if (side === "yes") return isEn ? `High lands in ${range}` : `最高温落在 ${range}`; if (side === "no") return isEn ? `High avoids ${range}` : `最高温不在 ${range}`; } const bucket = formatThreshold(row, tempSymbol); if (side === "yes") return isEn ? `High lands on ${bucket}` : `最高温落在 ${bucket} 桶`; if (side === "no") return isEn ? `High avoids ${bucket}` : `最高温不落在 ${bucket} 桶`; if (row.action) { return normalizeTemperatureLabel( String(row.action).replace(String(row.target_label || ""), ""), tempSymbol, ) .replace(/\s+/g, " ") .trim() .toUpperCase(); } return locale === "en-US" ? "WATCH" : "观察"; } function formatThreshold(row: ScanOpportunityRow, tempSymbol?: string | null) { const targetLabel = normalizeTemperatureLabel(row.target_label, tempSymbol); if (targetLabel) return targetLabel; if (row.target_lower != null && row.target_upper != null) { return `${formatTemperatureValue(Number(row.target_lower), tempSymbol)} ~ ${formatTemperatureValue(Number(row.target_upper), tempSymbol)}`; } if (row.target_threshold != null) { return formatTemperatureValue(Number(row.target_threshold), tempSymbol); } if (row.target_value != null) { return formatTemperatureValue(Number(row.target_value), tempSymbol); } return "--"; } function formatTemperatureDelta(value: number, tempSymbol?: string | null) { return formatTemperatureValue(Math.abs(value), tempSymbol, { digits: 1 }); } 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 })}`; } 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`; } 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 未冲突"; } 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", }; } function getLocalizedRowText( row: ScanOpportunityRow, locale: string, zh?: string | null, en?: string | null, ) { return locale === "en-US" ? en || zh || null : zh || en || null; } function formatModelSources(row: ScanOpportunityRow, tempSymbol?: string | null) { const sources = row.model_cluster_sources || {}; return Object.entries(sources) .filter(([, value]) => value != null && Number.isFinite(Number(value))) .sort(([left], [right]) => left.localeCompare(right)) .map(([name, value]) => ({ name, value: formatTemperatureValue(Number(value), tempSymbol, { digits: 1 }), })); } function formatModelClusterRange( sources?: Record | null, tempSymbol?: string | null, ) { const values = Object.values(sources || {}) .map((value) => Number(value)) .filter((value) => Number.isFinite(value)); if (!values.length) return "--"; const low = Math.min(...values); const high = Math.max(...values); if (Math.abs(low - high) < 0.05) { return formatTemperatureValue(low, tempSymbol, { digits: 1 }); } return `${formatTemperatureValue(low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(high, tempSymbol, { digits: 1 })}`; } function getModelSourceSummary( row: ScanOpportunityRow, locale: string, tempSymbol?: string | null, ) { const sources = formatModelSources(row, tempSymbol); if (!sources.length) { return locale === "en-US" ? "model cluster pending" : "模型集群暂未回传"; } const shown = sources.map((item) => `${item.name} ${item.value}`).join(" / "); return locale === "en-US" ? `all models: ${shown}` : `全部模型:${shown}`; } 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}确认。`; } 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; } 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); } 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/规则快照没有硬性排除项。", ]; } 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; } type ObservationPoint = { time?: string; temp?: number | null }; type V4TradeDecision = { decision: "approve" | "downgrade" | "veto" | "watchlist"; label: string; tone: "approve" | "downgrade" | "veto" | "watchlist"; reason: string; metarSummary?: string | null; airportReport?: string | null; metarEvidence: string[]; }; type V4CityForecast = { predicted: number | null; low: number | null; high: number | null; confidence?: string | null; peakWindow?: string | null; airportRead?: string | null; weatherRead?: string | null; paceRead?: string | null; paceTone?: "warm" | "cold" | "neutral" | string | null; paceDelta?: number | null; paceAdjustedHigh?: number | null; reason?: string | null; modelNote?: string | null; source: "ai" | "fallback"; }; function normalizeLookupKey(value?: string | null) { return String(value || "") .trim() .toLowerCase() .replace(/[\s_-]+/g, ""); } function getDetailForRow( row: Pick, cityDetailsByName?: Record, ) { if (!cityDetailsByName) return null; const rowKeys = [row.city, row.city_display_name, row.display_name] .map(normalizeLookupKey) .filter(Boolean); return ( Object.entries(cityDetailsByName).find(([name, detail]) => { const detailKeys = [name, detail.name, detail.display_name] .map(normalizeLookupKey) .filter(Boolean); return rowKeys.some((key) => detailKeys.includes(key)); })?.[1] || null ); } function getDetailViewDate(detail: CityDetail, row?: ScanOpportunityRow | null) { if (!row) return detail.local_date; const rawDate = row.selected_date || row.local_date || ""; const phase = String(row.window_phase || "").toLowerCase(); if ((phase === "tomorrow" || phase === "week_ahead") && rawDate) return rawDate; if (!rawDate || rawDate === detail.local_date || row.local_date === detail.local_date) { return detail.local_date; } return detail.local_date || rawDate; } function normalizeBucketLabel(value?: string | null, tempSymbol?: string | null) { return normalizeTemperatureLabel(value, tempSymbol) .toLowerCase() .replace(/\s+/g, "") .replace(/℃/g, "°c"); } function extractNumbers(value?: string | null) { return Array.from(String(value || "").matchAll(/-?\d+(?:\.\d+)?/g)).map((match) => Number(match[0]), ); } function normalizeObservationPoints(points?: ObservationPoint[] | null) { if (!Array.isArray(points)) return []; return points .map((point) => ({ time: String(point?.time || "").trim(), temp: point?.temp != null && Number.isFinite(Number(point.temp)) ? Number(point.temp) : null, })) .filter((point): point is { time: string; temp: number } => Boolean(point.time && point.temp != null), ) .sort((a, b) => getObservationSortMinutes(a.time) - getObservationSortMinutes(b.time)); } function getObservationSortMinutes(time: string) { const parsed = Date.parse(time); if (Number.isFinite(parsed)) { const date = new Date(parsed); return date.getUTCHours() * 60 + date.getUTCMinutes(); } const match = time.match(/(\d{1,2}):(\d{2})/); if (!match) return Number.MAX_SAFE_INTEGER; return Number(match[1]) * 60 + Number(match[2]); } function formatPeakWindowTiming(row: ScanOpportunityRow, locale: string) { const isEn = locale === "en-US"; const phase = String(row.window_phase || "").toLowerCase(); const label = String(row.peak_window_label || "").trim(); const untilStart = row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start)) ? Number(row.minutes_until_peak_start) : null; const untilEnd = row.minutes_until_peak_end != null && Number.isFinite(Number(row.minutes_until_peak_end)) ? Number(row.minutes_until_peak_end) : null; const windowText = label ? `${isEn ? "peak window" : "峰值窗口"} ${label}` : isEn ? "peak window" : "峰值窗口"; if (phase === "active_peak" || (untilStart != null && untilStart <= 0 && untilEnd != null && untilEnd > 0)) { return isEn ? `Currently inside the ${windowText}.` : `当前已进入${windowText}。`; } if (phase === "post_peak" || (untilEnd != null && untilEnd <= 0)) { return isEn ? `The ${windowText} has passed.` : `${windowText}已结束。`; } if (untilStart != null && untilStart > 0) { return isEn ? `${windowText} starts in ${formatMinuteSpan(untilStart, locale)}.` : `${windowText}尚未开始,约 ${formatMinuteSpan(untilStart, locale)} 后进入。`; } if (phase === "early_today" || phase === "setup_today") { return isEn ? `Before the ${windowText}; latest METAR is not final peak evidence yet.` : `尚处峰值前,最新 METAR 还不能当作最终峰值证据。`; } return label ? (isEn ? `Reference ${windowText}.` : `参考${windowText}。`) : null; } function decodeRawMetarCloud(rawMetar?: string | null, locale = "zh-CN") { const raw = String(rawMetar || "").toUpperCase(); const matches = Array.from(raw.matchAll(/\b(FEW|SCT|BKN|OVC)(\d{3})?\b/g)); if (!matches.length) return ""; const coverText: Record = { FEW: { zh: "少云", en: "few" }, SCT: { zh: "散云", en: "scattered" }, BKN: { zh: "多云", en: "broken" }, OVC: { zh: "阴天", en: "overcast" }, }; return matches .slice(0, 3) .map((match) => { const cover = coverText[match[1]] || { zh: match[1], en: match[1] }; const base = match[2] ? `${Number(match[2]) * 100}ft` : ""; return locale === "en-US" ? [cover.en, base].filter(Boolean).join(" ") : [cover.zh, base].filter(Boolean).join(" "); }) .join(locale === "en-US" ? ", " : "、"); } function decodeRawMetarVisibility(rawMetar?: string | null) { const raw = String(rawMetar || "").toUpperCase(); if (/\b9999\b/.test(raw)) return "10km+"; const meterMatch = raw.match(/\b(\d{4})\b/); if (meterMatch) return `${Number(meterMatch[1]) / 1000}km`; return ""; } function decodeMetarWeatherToken(token?: string | null, locale = "zh-CN") { const raw = String(token || "").trim().toUpperCase(); if (!raw) return ""; const isEn = locale === "en-US"; const intensity = raw.startsWith("-") ? isEn ? "light " : "轻" : raw.startsWith("+") ? isEn ? "heavy " : "强" : ""; const cleaned = raw.replace(/^[+-]/, ""); const descriptors: Record = { VC: { zh: "附近", en: "nearby " }, SH: { zh: "阵性", en: "showery " }, TS: { zh: "雷暴性", en: "thunderstorm " }, FZ: { zh: "冻", en: "freezing " }, BL: { zh: "吹扬", en: "blowing " }, DR: { zh: "低吹", en: "drifting " }, MI: { zh: "浅层", en: "shallow " }, BC: { zh: "碎片状", en: "patches of " }, PR: { zh: "部分", en: "partial " }, }; const phenomena: Record = { DZ: { zh: "毛毛雨", en: "drizzle" }, RA: { zh: "雨", en: "rain" }, SN: { zh: "雪", en: "snow" }, SG: { zh: "米雪", en: "snow grains" }, IC: { zh: "冰晶", en: "ice crystals" }, PL: { zh: "冰粒", en: "ice pellets" }, GR: { zh: "冰雹", en: "hail" }, GS: { zh: "小冰雹", en: "small hail" }, UP: { zh: "未知降水", en: "unknown precipitation" }, BR: { zh: "薄雾", en: "mist" }, FG: { zh: "雾", en: "fog" }, FU: { zh: "烟", en: "smoke" }, VA: { zh: "火山灰", en: "volcanic ash" }, DU: { zh: "浮尘", en: "dust" }, SA: { zh: "沙", en: "sand" }, HZ: { zh: "霾", en: "haze" }, PY: { zh: "喷雾", en: "spray" }, PO: { zh: "尘卷风", en: "dust whirls" }, SQ: { zh: "飑", en: "squall" }, FC: { zh: "漏斗云", en: "funnel cloud" }, SS: { zh: "沙暴", en: "sandstorm" }, DS: { zh: "尘暴", en: "duststorm" }, }; const descriptorText = Object.entries(descriptors) .filter(([code]) => cleaned.includes(code)) .map(([, text]) => (isEn ? text.en : text.zh)) .join(""); const phenomenonText = Object.entries(phenomena) .filter(([code]) => cleaned.includes(code)) .map(([, text]) => (isEn ? text.en : text.zh)) .join(isEn ? " / " : "、"); if (!phenomenonText) return ""; return `${intensity}${descriptorText}${phenomenonText}`; } function decodeRawMetarWeather(rawMetar?: string | null, locale = "zh-CN") { const raw = String(rawMetar || "").toUpperCase(); const matches = Array.from( raw.matchAll(/\b([+-]?(?:VC)?(?:MI|PR|BC|DR|BL|SH|TS|FZ)?(?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS))\b/g), ); return Array.from( new Set( matches .map((match) => decodeMetarWeatherToken(match[1], locale)) .filter(Boolean), ), ).join(locale === "en-US" ? ", " : "、"); } function getAirportWeatherInputs(row: ScanOpportunityRow, detail: CityDetail | null) { const context = row.metar_context || {}; const airport: Partial> = detail?.airport_current || {}; const rawMetar = String(context.airport_raw_metar || airport.raw_metar || "").trim(); return { cloud: String(context.airport_cloud_desc || airport.cloud_desc || "").trim(), rawMetar, visibility: context.airport_visibility_mi != null && Number.isFinite(Number(context.airport_visibility_mi)) ? Number(context.airport_visibility_mi) : airport.visibility_mi != null && Number.isFinite(Number(airport.visibility_mi)) ? Number(airport.visibility_mi) : null, weather: String(context.airport_wx_desc || airport.wx_desc || "").trim(), windSpeed: context.airport_wind_speed_kt != null && Number.isFinite(Number(context.airport_wind_speed_kt)) ? Number(context.airport_wind_speed_kt) : airport.wind_speed_kt != null && Number.isFinite(Number(airport.wind_speed_kt)) ? Number(airport.wind_speed_kt) : null, }; } function formatAirportWeatherRead( row: ScanOpportunityRow, detail: CityDetail | null, locale: string, ) { const isEn = locale === "en-US"; const inputs = getAirportWeatherInputs(row, detail); const decodedCloud = inputs.cloud || decodeRawMetarCloud(inputs.rawMetar, locale); const decodedWeather = decodeMetarWeatherToken(inputs.weather, locale) || inputs.weather || decodeRawMetarWeather(inputs.rawMetar, locale); const visibilityText = inputs.visibility != null ? `${inputs.visibility.toFixed(1)}mi` : decodeRawMetarVisibility(inputs.rawMetar); const cloudRaw = `${inputs.cloud} ${inputs.rawMetar}`.toUpperCase(); const weatherRaw = `${inputs.weather} ${inputs.rawMetar}`.toUpperCase(); const suppressors: string[] = []; const supporters: string[] = []; if (/(RA|DZ|SN|TS|SH|FG|BR|HZ|OVC|BKN)/.test(weatherRaw) || /(OVC|BKN)/.test(cloudRaw)) { suppressors.push( isEn ? "cloud, precipitation or restricted visibility can suppress solar heating" : "云雨、薄雾或低能见度会压制太阳辐射升温", ); } if (inputs.visibility != null && inputs.visibility < 6) { suppressors.push( isEn ? `visibility is only ${visibilityText}, so the airport path may warm more slowly` : `能见度仅 ${visibilityText},机场路径可能升温偏慢`, ); } if (/(FEW|SCT)/.test(cloudRaw) && !/(RA|DZ|SN|TS|FG|BR|HZ|OVC|BKN)/.test(weatherRaw)) { supporters.push( isEn ? "few or scattered clouds do not block the heating path materially" : "少云或散云对日间升温压制不明显", ); } if (inputs.windSpeed != null && inputs.windSpeed >= 15) { suppressors.push( isEn ? "stronger wind mixing can change the airport temperature path" : "风速偏大,边界层混合可能改写机场温度路径", ); } else if (inputs.windSpeed != null && inputs.windSpeed <= 5 && !suppressors.length) { supporters.push( isEn ? "light wind leaves the temperature path mainly driven by local sunshine" : "风速较弱,温度路径更取决于本地日照", ); } const descriptors = [ decodedWeather ? (isEn ? `weather ${decodedWeather}` : `天气 ${decodedWeather}`) : null, decodedCloud ? (isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`) : null, visibilityText ? (isEn ? `visibility ${visibilityText}` : `能见度 ${visibilityText}`) : null, ].filter(Boolean); const read = suppressors[0] || supporters[0]; if (!descriptors.length && !read) return null; const prefix = isEn ? "Airport weather read" : "机场气象解读"; const evidence = descriptors.length ? `${descriptors.join(isEn ? ", " : ",")};` : ""; return `${prefix}:${evidence}${read || (isEn ? "no clear weather suppression signal yet" : "暂未看到明确天气压温信号")}。`; } function formatAirportReportRead( row: ScanOpportunityRow, detail: CityDetail | null, locale: string, tempSymbol?: string | null, ) { const isEn = locale === "en-US"; const context = row.metar_context || {}; const airport: Partial> = detail?.airport_current || {}; const station = context.station || detail?.risk?.icao || airport.station_code || null; const obsTime = context.airport_obs_time || context.last_time || airport.obs_time || row.metar_status?.last_observation_time || null; const temp = context.airport_current_temp != null && Number.isFinite(Number(context.airport_current_temp)) ? Number(context.airport_current_temp) : airport.temp != null && Number.isFinite(Number(airport.temp)) ? Number(airport.temp) : null; const windSpeed = context.airport_wind_speed_kt != null && Number.isFinite(Number(context.airport_wind_speed_kt)) ? Number(context.airport_wind_speed_kt) : airport.wind_speed_kt != null && Number.isFinite(Number(airport.wind_speed_kt)) ? Number(airport.wind_speed_kt) : null; const windDir = context.airport_wind_dir != null && Number.isFinite(Number(context.airport_wind_dir)) ? Number(context.airport_wind_dir) : airport.wind_dir != null && Number.isFinite(Number(airport.wind_dir)) ? Number(airport.wind_dir) : null; const cloud = String(context.airport_cloud_desc || airport.cloud_desc || "").trim(); const weather = String(context.airport_wx_desc || airport.wx_desc || "").trim(); const rawMetar = String(context.airport_raw_metar || airport.raw_metar || "").trim(); const decodedCloud = cloud || decodeRawMetarCloud(rawMetar, locale); const decodedWeather = decodeMetarWeatherToken(weather, locale) || weather || decodeRawMetarWeather(rawMetar, locale); const visibility = context.airport_visibility_mi != null && Number.isFinite(Number(context.airport_visibility_mi)) ? Number(context.airport_visibility_mi) : airport.visibility_mi != null && Number.isFinite(Number(airport.visibility_mi)) ? Number(airport.visibility_mi) : null; const decodedVisibility = visibility != null ? `${visibility.toFixed(1)}mi` : decodeRawMetarVisibility(rawMetar); const parts: string[] = []; if (temp != null) parts.push(formatTemperatureValue(temp, tempSymbol, { digits: 1 })); if (windSpeed != null) { parts.push( windDir != null ? isEn ? `wind ${Math.round(windDir)}°/${Math.round(windSpeed)}kt` : `风 ${Math.round(windDir)}°/${Math.round(windSpeed)}kt` : isEn ? `wind ${Math.round(windSpeed)}kt` : `风 ${Math.round(windSpeed)}kt`, ); } if (decodedCloud) parts.push(isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`); if (decodedWeather) parts.push(isEn ? `weather ${decodedWeather}` : `天气 ${decodedWeather}`); if (decodedVisibility) parts.push(isEn ? `visibility ${decodedVisibility}` : `能见度 ${decodedVisibility}`); if (!parts.length) return null; const prefix = isEn ? "Latest airport METAR read" : "最新机场报文解读"; const head = [station, obsTime].filter(Boolean).join(" "); return `${prefix}${head ? ` ${head}` : ""}:${parts.join(",")}。`; } function getPaceDeviationRead( detail: CityDetail | null, locale: string, tempSymbol?: string | null, ) { if (!detail) return null; const paceView = getTodayPaceView(detail, locale === "en-US" ? "en-US" : "zh-CN"); if (!paceView) return null; const unit = normalizeTemperatureSymbol(tempSymbol || detail.temp_symbol); const observed = formatTemperatureValue(paceView.observedNow, unit, { digits: 1 }); const expected = formatTemperatureValue(paceView.expectedNow, unit, { digits: 1 }); const delta = `${paceView.delta > 0 ? "+" : ""}${paceView.delta.toFixed(1)}${unit}`; const isEn = locale === "en-US"; const toneText = paceView.biasTone === "warm" ? isEn ? "running hotter" : "偏热" : paceView.biasTone === "cold" ? isEn ? "running cooler" : "偏冷" : isEn ? "tracking" : "基本跟踪"; return { adjustedHigh: paceView.paceAdjustedHigh, delta: paceView.delta, label: paceView.badge, read: isEn ? `Observed path vs DEB curve: ${observed} now vs ${expected} expected, ${delta} (${toneText}).` : `实测路径对比 DEB 曲线:当前 ${observed},同刻预期 ${expected},偏差 ${delta}(${toneText})。`, tone: paceView.biasTone, }; } function getPaceSignalLabel(forecast: V4CityForecast, locale: string, tempSymbol?: string | null) { const isEn = locale === "en-US"; if (forecast.paceDelta == null || !Number.isFinite(Number(forecast.paceDelta))) { return isEn ? "Path pending" : "路径待确认"; } const unit = normalizeTemperatureSymbol(tempSymbol); const delta = `${forecast.paceDelta > 0 ? "+" : ""}${Number(forecast.paceDelta).toFixed(1)}${unit}`; if (forecast.paceTone === "warm") return isEn ? `Hot path ${delta}` : `实测偏热 ${delta}`; if (forecast.paceTone === "cold") return isEn ? `Cool path ${delta}` : `实测偏冷 ${delta}`; return isEn ? `On path ${delta}` : `路径跟踪 ${delta}`; } function getPaceDecisionTail(forecast: V4CityForecast, locale: string, tempSymbol?: string | null) { if (forecast.paceDelta == null || !Number.isFinite(Number(forecast.paceDelta))) return ""; const isEn = locale === "en-US"; const unit = normalizeTemperatureSymbol(tempSymbol); const delta = `${forecast.paceDelta > 0 ? "+" : ""}${Number(forecast.paceDelta).toFixed(1)}${unit}`; if (forecast.paceTone === "warm") { return isEn ? ` Observations are running ${delta} above the DEB path, so upside boundaries need extra caution.` : ` 实测比 DEB 路径偏高 ${delta},上方阈值要额外谨慎。`; } if (forecast.paceTone === "cold") { return isEn ? ` Observations are running ${delta} below the DEB path, which weakens upside breakout odds.` : ` 实测比 DEB 路径偏低 ${delta},上破概率需要下修。`; } return isEn ? " Observations are still tracking the DEB path." : " 实测仍基本跟踪 DEB 路径。"; } function median(values: number[]) { if (!values.length) return null; const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } function getV4CityForecast( row: ScanOpportunityRow, group: OpportunityGroup, detail: CityDetail | null, locale: string, tempSymbol?: string | null, ): V4CityForecast { const isEn = locale === "en-US"; const aiPredicted = row.ai_predicted_max != null && Number.isFinite(Number(row.ai_predicted_max)) ? Number(row.ai_predicted_max) : null; const aiLow = row.ai_predicted_low != null && Number.isFinite(Number(row.ai_predicted_low)) ? Number(row.ai_predicted_low) : null; const aiHigh = row.ai_predicted_high != null && Number.isFinite(Number(row.ai_predicted_high)) ? Number(row.ai_predicted_high) : null; const modelValues = Object.values(row.model_cluster_sources || {}) .map((value) => Number(value)) .filter((value) => Number.isFinite(value)); const fallbackPredicted = aiPredicted ?? (row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction)) ? Number(row.deb_prediction) : median(modelValues)); const fallbackLow = aiLow ?? (modelValues.length ? Math.min(...modelValues) : fallbackPredicted); const fallbackHigh = aiHigh ?? (modelValues.length ? Math.max(...modelValues) : fallbackPredicted); const peakWindow = getLocalizedRowText(row, locale, row.ai_peak_window_zh, row.ai_peak_window_en) || formatPeakWindowTiming(row, locale); const airportRead = getLocalizedRowText( row, locale, row.ai_airport_metar_read_zh, row.ai_airport_metar_read_en, ) || formatAirportReportRead(row, detail, locale, tempSymbol); const weatherRead = formatAirportWeatherRead(row, detail, locale); const paceRead = getPaceDeviationRead(detail, locale, tempSymbol); const modelNote = row.ai_city_model_cluster_note || row.ai_model_cluster_note || getModelSourceSummary(row, locale, tempSymbol); const reason = getLocalizedRowText(row, locale, row.ai_forecast_reason_zh, row.ai_forecast_reason_en) || getLocalizedRowText(row, locale, row.ai_city_thesis_zh, row.ai_city_thesis_en) || (fallbackPredicted != null ? isEn ? `${group.cityName} final high is centered near ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })}; market temperature buckets are only mapped against that forecast range.` : `${group.cityName} 最终最高温先以 ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })} 附近为中枢,市场温度桶只用于对照 AI 预测区间。` : null); return { predicted: fallbackPredicted, low: fallbackLow, high: fallbackHigh, confidence: row.ai_forecast_confidence || row.ai_city_confidence, peakWindow, airportRead, weatherRead, paceRead: paceRead?.read || null, paceTone: paceRead?.tone || null, paceDelta: paceRead?.delta ?? null, paceAdjustedHigh: paceRead?.adjustedHigh ?? null, reason, modelNote, source: aiPredicted != null ? "ai" : "fallback", }; } function getForecastRangeLabel(forecast: V4CityForecast, tempSymbol?: string | null) { if (forecast.low == null && forecast.high == null) return "--"; if (forecast.low != null && forecast.high != null) { if (Math.abs(forecast.low - forecast.high) < 0.05) { return formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 }); } return `${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(forecast.high, tempSymbol, { digits: 1 })}`; } if (forecast.low != null) return `>= ${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })}`; return `<= ${formatTemperatureValue(Number(forecast.high), tempSymbol, { digits: 1 })}`; } function getForecastContractFit( row: ScanOpportunityRow, forecast: V4CityForecast, locale: string, tempSymbol?: string | null, ) { const isEn = locale === "en-US"; const explicitMatch = String(row.ai_forecast_match || "").toLowerCase(); const explicitReason = getLocalizedRowText( row, locale, row.ai_forecast_match_reason_zh, row.ai_forecast_match_reason_en, ); if (explicitMatch && explicitReason) { return { label: explicitMatch === "core" ? isEn ? "Core bucket" : "核心桶" : explicitMatch === "outside" ? isEn ? "Outside forecast" : "预测区间外" : explicitMatch === "edge" ? isEn ? "Boundary bucket" : "边界桶" : isEn ? "Watch" : "观察", tone: explicitMatch === "core" ? "approve" : explicitMatch === "outside" ? "veto" : "watchlist", reason: explicitReason, }; } const { lower, upper } = getTargetRange(row); const predicted = forecast.predicted; if (predicted == null || (lower == null && upper == null)) { return { label: isEn ? "Await forecast" : "等待预测", tone: "watchlist", reason: isEn ? "AI has no stable max-temperature center yet." : "AI 还没有稳定的最高温中枢。", }; } const unit = normalizeTemperatureSymbol(tempSymbol); const tolerance = String(unit).toUpperCase().includes("F") ? 1.0 : 0.5; const inside = (lower == null || predicted >= lower - tolerance) && (upper == null || predicted <= upper + tolerance); const rangeOverlaps = forecast.low != null && forecast.high != null && (lower == null || forecast.high >= lower - tolerance) && (upper == null || forecast.low <= upper + tolerance); if (inside) { return { label: isEn ? "Core bucket" : "核心桶", tone: "approve", reason: isEn ? `AI max-temperature center ${formatTemperatureValue(predicted, unit, { digits: 1 })} sits inside this bucket.` : `AI 最高温中枢 ${formatTemperatureValue(predicted, unit, { digits: 1 })} 落在这个温度桶内。`, }; } if (rangeOverlaps) { return { label: isEn ? "Boundary bucket" : "边界桶", tone: "watchlist", reason: isEn ? `This bucket touches the AI interval ${getForecastRangeLabel(forecast, unit)}, but is not the center.` : `该桶触及 AI 区间 ${getForecastRangeLabel(forecast, unit)},但不是预测中枢。`, }; } return { label: isEn ? "Outside forecast" : "预测区间外", tone: "veto", reason: isEn ? `This bucket is outside the AI max-temperature interval ${getForecastRangeLabel(forecast, unit)}.` : `该桶位于 AI 最高温区间 ${getForecastRangeLabel(forecast, unit)} 之外。`, }; } function firstNonEmptyPoints(...groups: Array>) { return groups.find((group) => group.length > 0) || []; } function getTargetRange(row: ScanOpportunityRow) { const lower = row.target_lower != null && Number.isFinite(Number(row.target_lower)) ? Number(row.target_lower) : null; const upper = row.target_upper != null && Number.isFinite(Number(row.target_upper)) ? Number(row.target_upper) : null; if (lower != null || upper != null) return { lower, upper }; const rawLabel = String(row.target_label || row.action || ""); const numbers = extractNumbers(rawLabel); if (numbers.length >= 2) { return { lower: Math.min(numbers[0], numbers[1]), upper: Math.max(numbers[0], numbers[1]) }; } const value = row.target_threshold ?? row.target_value ?? (numbers.length ? numbers[0] : null); if (value == null || !Number.isFinite(Number(value))) { return { lower: null, upper: null }; } const numeric = Number(value); if (/(\+|above|higher|or\s+higher|>=|≥|以上)/i.test(rawLabel)) { return { lower: numeric, upper: null }; } if (/(below|or\s+below|<=|≤|以下)/i.test(rawLabel)) { return { lower: null, upper: numeric }; } return { lower: numeric, upper: numeric }; } function getMetarObservationContext( row: ScanOpportunityRow, detail: CityDetail | null, ) { const context = row.metar_context || {}; const metarToday = firstNonEmptyPoints( normalizeObservationPoints(context.today_obs), normalizeObservationPoints(row.metar_today_obs), ); const detailMetarToday = normalizeObservationPoints(detail?.metar_today_obs); const metarRecent = firstNonEmptyPoints( normalizeObservationPoints(context.recent_obs), normalizeObservationPoints(row.metar_recent_obs), ); const detailMetarRecent = normalizeObservationPoints(detail?.metar_recent_obs); const settlementToday = firstNonEmptyPoints( normalizeObservationPoints(context.settlement_today_obs), normalizeObservationPoints(row.settlement_today_obs), ); const detailSettlementToday = normalizeObservationPoints(detail?.settlement_today_obs); const primaryPoints = metarToday.length ? metarToday : detailMetarToday.length ? detailMetarToday : metarRecent.length ? metarRecent : detailMetarRecent.length ? detailMetarRecent : settlementToday.length ? settlementToday : detailSettlementToday; const trendPoints = (metarRecent.length ? metarRecent : detailMetarRecent.length ? detailMetarRecent : primaryPoints.slice(-4)); const explicitLast = context.last_temp ?? row.metar_status?.last_temp ?? detail?.metar_status?.last_temp; const lastPoint = primaryPoints[primaryPoints.length - 1] || null; const maxPoint = primaryPoints.reduce<{ time: string; temp: number } | null>( (best, point) => (!best || point.temp >= best.temp ? point : best), null, ); const trendFirst = trendPoints[0] || null; const trendLast = trendPoints[trendPoints.length - 1] || null; const trendDelta = context.trend_delta != null && Number.isFinite(Number(context.trend_delta)) ? Number(context.trend_delta) : trendFirst && trendLast && trendPoints.length >= 2 ? trendLast.temp - trendFirst.temp : null; const lastTemp = explicitLast != null && Number.isFinite(Number(explicitLast)) ? Number(explicitLast) : lastPoint?.temp ?? null; const maxTemp = context.max_temp != null && Number.isFinite(Number(context.max_temp)) ? Number(context.max_temp) : maxPoint?.temp ?? null; const stale = context.stale_for_today === true || row.metar_status?.stale_for_today === true || detail?.metar_status?.stale_for_today === true; return { points: primaryPoints, lastTime: String(context.last_time || lastPoint?.time || ""), lastTemp, maxTime: String(context.max_time || maxPoint?.time || ""), maxTemp, trendDelta, stale, station: context.station || detail?.risk?.icao || detail?.airport_current?.station_code || null, }; } function getMetarGate( row: ScanOpportunityRow, detail: CityDetail | null, locale: string, tempSymbol?: string | null, ) { const isEn = locale === "en-US"; const side = String(row.side || "").toLowerCase(); const selectedDate = String(row.selected_date || ""); const localDate = String(row.local_date || detail?.local_date || ""); const futureContract = Boolean(selectedDate && localDate && selectedDate > localDate); if (futureContract) return null; const obs = getMetarObservationContext(row, detail); const evidence: string[] = []; const unit = normalizeTemperatureSymbol(tempSymbol); const peakTiming = formatPeakWindowTiming(row, locale); const airportReport = formatAirportReportRead(row, detail, locale, unit); const airportWeatherRead = formatAirportWeatherRead(row, detail, locale); if (peakTiming) evidence.push(peakTiming); if (airportReport) evidence.push(airportReport); if (airportWeatherRead) evidence.push(airportWeatherRead); if (obs.lastTemp != null) { evidence.push( `${isEn ? "METAR latest" : "METAR 最新"} ${formatTemperatureValue(obs.lastTemp, unit, { digits: 1 })}${obs.lastTime ? ` @ ${obs.lastTime}` : ""}`, ); } if (obs.maxTemp != null) { evidence.push( `${isEn ? "METAR max" : "METAR 最高"} ${formatTemperatureValue(obs.maxTemp, unit, { digits: 1 })}${obs.maxTime ? ` @ ${obs.maxTime}` : ""}`, ); } if (obs.trendDelta != null) { evidence.push( `${isEn ? "Recent METAR delta" : "近端 METAR 变化"} ${formatTemperatureValue(obs.trendDelta, unit, { digits: 1 })}`, ); } if (obs.station) evidence.push(`${isEn ? "Station" : "站点"} ${obs.station}`); if (obs.stale || !obs.points.length || obs.maxTemp == null) { return { decision: "downgrade" as const, reason: isEn ? "AI has no same-day METAR confirmation yet, so this bucket remains forecast-only." : "AI 还没有拿到同日 METAR 确认,该桶暂时只能作为预测映射。", evidence, }; } const { lower, upper } = getTargetRange(row); if (lower == null && upper == null) { return { decision: "watchlist" as const, reason: isEn ? "AI has METAR data, but the contract threshold cannot be mapped cleanly to a bucket." : "AI 已读取 METAR,但该合约阈值无法稳定映射到温度桶。", evidence, }; } const epsilon = String(unit).toUpperCase().includes("F") ? 0.7 : 0.4; const trendDelta = obs.trendDelta; const isNotRising = trendDelta != null && trendDelta <= epsilon; const isFalling = trendDelta != null && trendDelta <= -epsilon; const phase = String(row.window_phase || "").toLowerCase(); const remaining = row.remaining_window_minutes != null && Number.isFinite(Number(row.remaining_window_minutes)) ? Number(row.remaining_window_minutes) : null; const minutesUntilPeakStart = row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start)) ? Number(row.minutes_until_peak_start) : null; const lateWindow = phase === "active_peak" || phase === "post_peak" || (remaining != null && remaining <= 180); const beforePeak = phase === "early_today" || phase === "setup_today" || phase === "tomorrow" || phase === "week_ahead" || (minutesUntilPeakStart != null && minutesUntilPeakStart > 0); const aboveUpper = upper != null && obs.maxTemp > upper + epsilon; const belowLower = lower != null && obs.maxTemp < lower - epsilon; const insideBucket = (lower == null || obs.maxTemp >= lower - epsilon) && (upper == null || obs.maxTemp <= upper + epsilon); if (side === "no") { if (aboveUpper) { return { decision: "approve" as const, reason: isEn ? "METAR max has already moved above this bucket, so AI marks the NO bucket as observation-supported." : "METAR 实测最高已越过目标桶上沿,AI 判断 NO 桶有实测支撑。", evidence, }; } if (belowLower && (lateWindow || isFalling || isNotRising)) { if (beforePeak && !lateWindow) { return { decision: "watchlist" as const, reason: isEn ? "The peak window has not arrived, so a still-low METAR path cannot confirm this NO bucket yet; AI keeps it on watch." : "峰值窗口尚未到来,METAR 暂未触达不能直接确认 NO 桶,AI 先列观察。", evidence, }; } return { decision: "approve" as const, reason: isEn ? "METAR max remains below this bucket and recent observations are not strengthening, so AI favors the NO bucket." : "METAR 最高仍低于目标桶且近期走势不强,AI 倾向 NO 桶。", evidence, }; } if (insideBucket && lateWindow && isNotRising) { return { decision: "downgrade" as const, reason: isEn ? "METAR max is still close to this bucket, so AI cannot treat the NO bucket as confirmed." : "METAR 最高仍贴近目标桶,AI 不能把 NO 桶视为已确认。", evidence, }; } } if (side === "yes") { if (aboveUpper) { return { decision: "veto" as const, reason: isEn ? "METAR max has already exceeded the bucket, so AI marks this YES bucket as outside the observed path." : "METAR 实测最高已越过目标桶上沿,AI 判断该 YES 桶已偏离实测路径。", evidence, }; } if (belowLower && (lateWindow || isFalling || isNotRising)) { if (beforePeak && !lateWindow) { return { decision: "watchlist" as const, reason: isEn ? "The peak window has not arrived, so METAR not reaching the bucket only means this bucket still needs peak-window confirmation." : "峰值窗口尚未到来,METAR 未触达目标桶只能说明仍需等待峰值验证,AI 暂列观察。", evidence, }; } return { decision: "downgrade" as const, reason: isEn ? "METAR max has not reached the bucket and recent observations are weak, so AI downgrades the YES bucket." : "METAR 最高仍未触达目标桶且走势不强,AI 将 YES 桶降级观察。", evidence, }; } if (insideBucket) { return { decision: "approve" as const, reason: isEn ? "METAR max is inside the target bucket, so AI sees observation support while monitoring an overshoot." : "METAR 实测最高已落入目标桶,AI 认为 YES 桶有实测依据,但仍需防止继续升穿上沿。", evidence, }; } } return { decision: "watchlist" as const, reason: isEn ? "METAR does not give a clean final confirmation yet, so AI keeps this as watchlist." : "METAR 还没有给出干净的最终确认,AI 暂列观察。", evidence, }; } 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 观察"; } 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 || [], }; } function getBucketText(bucket: { label?: string | null; bucket?: string | null; range?: string | null }) { return [bucket.label, bucket.bucket, bucket.range] .map((value) => String(value || "").trim()) .filter(Boolean); } function bucketMatchesRow( bucket: { label?: string | null; bucket?: string | null; range?: string | null; value?: number | string | null; }, row: ScanOpportunityRow, tempSymbol?: string | null, ) { const targetLabel = normalizeBucketLabel(row.target_label, tempSymbol); const bucketLabels = getBucketText(bucket).map((label) => normalizeBucketLabel(label, tempSymbol), ); if (targetLabel && bucketLabels.some((label) => label === targetLabel)) { return true; } const rawTargetLabel = String(row.target_label || ""); const targetNumbers = extractNumbers(rawTargetLabel); const targetValue = row.target_value ?? row.target_threshold ?? row.target_lower ?? row.target_upper ?? targetNumbers[0] ?? null; if (targetValue == null || !Number.isFinite(Number(targetValue))) return false; const bucketNumbers = [ ...(bucket.value != null && Number.isFinite(Number(bucket.value)) ? [Number(bucket.value)] : []), ...getBucketText(bucket).flatMap(extractNumbers), ]; const matchesNumber = bucketNumbers.some( (value) => Math.abs(Number(value) - Number(targetValue)) < 0.05, ); if (!matchesNumber) return false; const targetIsUpper = /(\+|以上|or\s*above|above|greater|>=|≥)/i.test(rawTargetLabel) || (row.target_lower != null && row.target_upper == null); const targetIsLower = /(<=|≤|below|or\s*below|以下)/i.test(rawTargetLabel) || (row.target_upper != null && row.target_lower == null); const bucketRaw = getBucketText(bucket).join(" "); const bucketIsUpper = /(\+|以上|or\s*above|above|greater|>=|≥|inf|∞)/i.test(bucketRaw); const bucketIsLower = /(<=|≤|below|or\s*below|以下|-inf|-∞)/i.test(bucketRaw); if (targetIsUpper || bucketIsUpper) return targetIsUpper === bucketIsUpper; if (targetIsLower || bucketIsLower) return targetIsLower === bucketIsLower; return true; } function getDetailBucketEventProbability( detail: CityDetail | null, row: ScanOpportunityRow, tempSymbol?: string | null, ) { if (!detail) return null; const view = getProbabilityView(detail, getDetailViewDate(detail, row)); const buckets = Array.isArray(view.probabilitiesAll) ? view.probabilitiesAll : []; if (!buckets.length) return null; const matched = buckets.find((bucket) => bucketMatchesRow(bucket, row, tempSymbol)); return normalizeProbability(matched?.probability); } type OpportunityGroup = { key: string; cityName: string; date?: string | null; tempSymbol?: string | null; debLabel: string; peakLabel: string; peakProbability?: number | null; phaseMeta: PhaseMeta; localTime?: string | null; remainingMinutes?: number | null; rows: ScanOpportunityRow[]; }; function buildOpportunityGroups( rows: ScanOpportunityRow[], locale: string, cityDetailsByName?: Record, ): OpportunityGroup[] { const groups = new Map(); for (const row of rows) { const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol); const detail = getDetailForRow(row, cityDetailsByName); const cityName = getLocalizedCityName( row.city, row.city_display_name || row.display_name || row.city, locale, ); const date = detail ? getDetailViewDate(detail, row) : row.selected_date || row.local_date || ""; const key = `${row.city || cityName}|${date}`; const modelView = detail ? getModelView(detail, date) : null; const debPrediction = modelView?.deb ?? row.deb_prediction ?? null; const modelClusterLabel = formatModelClusterRange( modelView?.models || row.model_cluster_sources, tempSymbol, ); const existing = groups.get(key); if (!existing) { groups.set(key, { key, cityName, date, tempSymbol, debLabel: debPrediction != null ? formatTemperatureValue(Number(debPrediction), tempSymbol, { digits: 1 }) : "--", peakLabel: modelClusterLabel, peakProbability: null, phaseMeta: getWindowPhaseMeta(row, locale), localTime: row.local_time, remainingMinutes: row.remaining_window_minutes, rows: [row], }); continue; } existing.rows.push(row); if (existing.peakLabel === "--" && modelClusterLabel !== "--") { existing.peakLabel = modelClusterLabel; } } return Array.from(groups.values()).map((group) => ({ ...group, rows: [...group.rows].sort( (a, b) => Number(b.edge_percent ?? -Infinity) - Number(a.edge_percent ?? -Infinity) || Number(b.final_score ?? -Infinity) - Number(a.final_score ?? -Infinity), ), })); } function getBucketDisplayLabel( row: ScanOpportunityRow, locale: string, tempSymbol?: string | null, ) { const isEn = locale === "en-US"; const { lower, upper } = getTargetRange(row); if (lower != null && upper == null) { const value = formatTemperatureValue(lower, tempSymbol); return isEn ? `${value} or higher` : `${value} 以上`; } if (upper != null && lower == null) { const value = formatTemperatureValue(upper, tempSymbol); return isEn ? `${value} or lower` : `${value} 以下`; } if (lower != null && upper != null && Math.abs(lower - upper) > 0.01) { return `${formatTemperatureValue(lower, tempSymbol)} ~ ${formatTemperatureValue(upper, tempSymbol)}`; } return formatThreshold(row, tempSymbol); } function getForecastFitMeta( fit: ReturnType, 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", }; } 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), }; } 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); } 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); } export const OpportunityTable = React.memo(function OpportunityTable({ rows, status, stale, staleReason, loading, selectedRowId, onSelectRow, cityDetailsByName, }: { rows: ScanOpportunityRow[]; status?: string | null; stale?: boolean; staleReason?: string | null; loading?: boolean; selectedRowId?: string | null; onSelectRow?: (row: ScanOpportunityRow) => void; cityDetailsByName?: Record; }) { const { locale } = useI18n(); const isEn = locale === "en-US"; const hasRows = rows.length > 0; const scanInProgress = loading || status === "partial" || status === "scanning"; const groups = React.useMemo( () => buildOpportunityGroups(rows, locale, cityDetailsByName), [rows, locale, cityDetailsByName], ); const [expandedRowIds, setExpandedRowIds] = React.useState>( () => new Set(), ); const toggleExpandedRow = React.useCallback((rowId: string) => { setExpandedRowIds((current) => { const next = new Set(current); if (next.has(rowId)) { next.delete(rowId); } else { next.add(rowId); } return next; }); }, []); const ensureExpandedRow = React.useCallback((rowId: string) => { setExpandedRowIds((current) => { if (current.has(rowId)) return current; const next = new Set(current); next.add(rowId); return next; }); }, []); const selectAndOpenRow = React.useCallback( (row: ScanOpportunityRow) => { ensureExpandedRow(row.id); onSelectRow?.(row); }, [ensureExpandedRow, onSelectRow], ); const toggleRowAnalysis = React.useCallback( (row: ScanOpportunityRow) => { toggleExpandedRow(row.id); onSelectRow?.(row); }, [onSelectRow, toggleExpandedRow], ); if (!hasRows) { const title = scanInProgress ? isEn ? "Scanning markets" : "正在扫描市场" : status === "failed" ? isEn ? "Scan failed" : "扫描失败" : isEn ? "No tradable market right now" : "当前暂无可交易市场"; const copy = scanInProgress ? isEn ? "Waiting for the latest market snapshot. Existing data will stay on screen when available." : "正在等待最新市场快照;如果有旧数据,会继续保留在页面上。" : status === "failed" ? staleReason || (isEn ? "No valid market snapshot is available." : "当前没有可用的市场快照。") : isEn ? "The current snapshot does not contain a tradable main signal." : "当前快照里还没有可交易的主信号。"; return (
{title}
{copy}
); } return (
{stale ? (
{isEn ? "Showing delayed snapshot" : "当前显示延迟快照"} {staleReason || (isEn ? "Latest refresh failed, fallback to the last successful scan." : "最新刷新失败,已回退到上次成功扫描结果。")}
) : null}
{groups.map((group) => { const groupSelected = group.rows.some((row) => row.id === selectedRowId); const firstRow = group.rows[0]; const firstTempSymbol = normalizeTemperatureSymbol( firstRow?.target_unit || firstRow?.temp_symbol || group.tempSymbol, ); const firstDetail = firstRow ? getDetailForRow(firstRow, cityDetailsByName) : null; const groupForecast = firstRow ? getV4CityForecast(firstRow, group, firstDetail, locale, firstTempSymbol) : null; const groupForecastLabel = groupForecast?.predicted != null ? formatTemperatureValue(groupForecast.predicted, firstTempSymbol, { digits: 1 }) : "--"; const groupRangeLabel = groupForecast ? getForecastRangeLabel(groupForecast, firstTempSymbol) : group.peakLabel; return (
{group.rows.map((row) => { const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol); const detail = getDetailForRow(row, cityDetailsByName); const debDistanceLabel = isEn ? "DEB distance" : "DEB 距离"; const modelSupportLabel = isEn ? "Model support" : "模型支持"; const metarLabel = "METAR"; const paceLabel = isEn ? "Path vs DEB" : "路径偏差"; const debDistanceText = getDebDistanceSummary(row, locale, tempSymbol); const modelSupportText = getModelSupportSummary(row, locale); const metarConflictText = getMetarConflictSummary(row, detail, locale); const cityForecast = getV4CityForecast( row, group, detail, locale, tempSymbol, ); const paceSignalText = getPaceSignalLabel(cityForecast, locale, tempSymbol); const aiMeta = getAiMeta(row, locale); const thresholdDecision = getThresholdDecision( row, cityForecast, locale, tempSymbol, ); const expanded = expandedRowIds.has(row.id); const shortConclusion = `${thresholdDecision.headline}。${thresholdDecision.summary}`; const keyReasons = getDecisionReasonItems( row, cityForecast, modelSupportText, locale, tempSymbol, ); const riskItems = getForecastRiskItems( row, detail, cityForecast, locale, tempSymbol, ); const bucketLabel = getBucketDisplayLabel(row, locale, tempSymbol); const forecastRangeLabel = getForecastRangeLabel(cityForecast, tempSymbol); return (
selectAndOpenRow(row)} >
{isEn ? "Conclusion" : "最终判断"} {thresholdDecision.headline} {thresholdDecision.summary}
{debDistanceLabel} {debDistanceText} {modelSupportLabel} {modelSupportText} {metarLabel} {metarConflictText} {paceLabel} {paceSignalText}
{thresholdDecision.relation}
{isEn ? "Current read" : "当前判断"} {shortConclusion}
{expanded ? (
{isEn ? "Conclusion" : "结论"}

{thresholdDecision.headline}

{thresholdDecision.summary}
{thresholdDecision.relation}
{isEn ? "Bucket" : "判断对象"} {bucketLabel} {isEn ? "AI forecast" : "AI 预测"} {cityForecast.predicted != null ? formatTemperatureValue(cityForecast.predicted, tempSymbol, { digits: 1 }) : "--"} {isEn ? "Forecast range" : "预测区间"} {forecastRangeLabel} {isEn ? "Confidence" : "信心"} {cityForecast.confidence || thresholdDecision.confidence}
DEB {group.debLabel} {modelSupportLabel} {modelSupportText} METAR {metarConflictText} {paceLabel} {paceSignalText}
{isEn ? "Why" : "为什么"}
    {keyReasons.map((reason) => (
  • {reason}
  • ))}
{isEn ? "What can change it" : "可能改变判断的因素"}
    {riskItems.map((reason) => (
  • {reason}
  • ))}
{cityForecast.airportRead ? (

{cityForecast.airportRead}

) : null} {cityForecast.weatherRead ? (

{cityForecast.weatherRead}

) : null} {cityForecast.paceRead ? (

{cityForecast.paceRead}

) : null} {cityForecast.peakWindow ? (

{cityForecast.peakWindow}

) : null}
) : null}
); })}
); })}
); });