2412 lines
91 KiB
TypeScript
2412 lines
91 KiB
TypeScript
"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<ScanOpportunityRow, "window_phase" | "trend_alignment">,
|
||
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<string, number | null> | 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<ScanOpportunityRow, "city" | "city_display_name" | "display_name">,
|
||
cityDetailsByName?: Record<string, CityDetail>,
|
||
) {
|
||
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<string, { zh: string; en: string }> = {
|
||
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<string, { zh: string; en: string }> = {
|
||
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<string, { zh: string; en: string }> = {
|
||
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<NonNullable<CityDetail["airport_current"]>> =
|
||
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<NonNullable<CityDetail["airport_current"]>> =
|
||
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<ReturnType<typeof normalizeObservationPoints>>) {
|
||
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<string, CityDetail>,
|
||
): OpportunityGroup[] {
|
||
const groups = new Map<string, OpportunityGroup>();
|
||
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<typeof getForecastContractFit>,
|
||
locale: string,
|
||
) {
|
||
const isEn = locale === "en-US";
|
||
const tone = String(fit.tone || "watchlist");
|
||
if (tone === "approve" || tone === "core") {
|
||
return {
|
||
label: isEn ? "Clear signal" : "方向明确",
|
||
tone: "approve",
|
||
};
|
||
}
|
||
if (tone === "veto" || tone === "outside") {
|
||
return {
|
||
label: isEn ? "Outside AI range" : "偏离 AI 区间",
|
||
tone: "veto",
|
||
};
|
||
}
|
||
if (tone === "downgrade") {
|
||
return {
|
||
label: isEn ? "Downgraded" : "降级观察",
|
||
tone: "downgrade",
|
||
};
|
||
}
|
||
return {
|
||
label: isEn ? "Need peak confirmation" : "等待峰值确认",
|
||
tone: "watchlist",
|
||
};
|
||
}
|
||
|
||
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<string, CityDetail>;
|
||
}) {
|
||
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<Set<string>>(
|
||
() => 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 (
|
||
<div className="scan-table-shell empty">
|
||
<div className="scan-empty-state">
|
||
<div className="scan-empty-title">{title}</div>
|
||
<div className="scan-empty-copy">{copy}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="scan-table-shell">
|
||
{stale ? (
|
||
<div className="scan-table-banner">
|
||
<strong>{isEn ? "Showing delayed snapshot" : "当前显示延迟快照"}</strong>
|
||
<span>{staleReason || (isEn ? "Latest refresh failed, fallback to the last successful scan." : "最新刷新失败,已回退到上次成功扫描结果。")}</span>
|
||
</div>
|
||
) : null}
|
||
<div className="scan-table-body scan-opportunity-groups scan-forecast-desk">
|
||
{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 (
|
||
<section
|
||
key={group.key}
|
||
className={`scan-opportunity-group scan-forecast-city-card ${groupSelected ? "selected" : ""}`}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="scan-opportunity-group-head scan-forecast-city-head"
|
||
onClick={() => {
|
||
const firstRow = group.rows[0];
|
||
if (firstRow) selectAndOpenRow(firstRow);
|
||
}}
|
||
>
|
||
<div className="scan-forecast-city-title">
|
||
<span className="scan-forecast-kicker">
|
||
{isEn ? "City max-temp read" : "城市最高温判断"}
|
||
</span>
|
||
<strong>{group.cityName}</strong>
|
||
<div className="scan-forecast-city-chips">
|
||
<span>{group.localTime || "--"}</span>
|
||
<span>{formatWindowMinutes(group.remainingMinutes, locale)}</span>
|
||
<span>DEB {group.debLabel}</span>
|
||
<span>{isEn ? "Models" : "模型"} {group.peakLabel}</span>
|
||
</div>
|
||
</div>
|
||
<div className="scan-forecast-city-read">
|
||
<small>{isEn ? "AI expected high" : "AI 预计最高温"}</small>
|
||
<b>{groupForecastLabel}</b>
|
||
<span>{isEn ? "Range" : "区间"} {groupRangeLabel}</span>
|
||
<b className={`scan-phase-badge ${group.phaseMeta.tone}`}>
|
||
{group.phaseMeta.label}
|
||
</b>
|
||
</div>
|
||
</button>
|
||
|
||
<div className="scan-opportunity-items">
|
||
{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 (
|
||
<div
|
||
key={row.id}
|
||
className={`scan-opportunity-item scan-forecast-row ${selectedRowId === row.id ? "selected" : ""} ${expanded ? "expanded" : ""} ai-fit-${thresholdDecision.tone} ${aiMeta ? `ai-${aiMeta.tone}` : ""}`}
|
||
onClick={() => selectAndOpenRow(row)}
|
||
>
|
||
<div className="scan-forecast-row-main">
|
||
<div className="scan-forecast-bucket">
|
||
<span>{isEn ? "Conclusion" : "最终判断"}</span>
|
||
<strong>{thresholdDecision.headline}</strong>
|
||
<small>{thresholdDecision.summary}</small>
|
||
</div>
|
||
<div className="scan-forecast-signals">
|
||
<span>
|
||
<small>{debDistanceLabel}</small>
|
||
<b>{debDistanceText}</b>
|
||
</span>
|
||
<span>
|
||
<small>{modelSupportLabel}</small>
|
||
<b>{modelSupportText}</b>
|
||
</span>
|
||
<span>
|
||
<small>{metarLabel}</small>
|
||
<b>{metarConflictText}</b>
|
||
</span>
|
||
<span>
|
||
<small>{paceLabel}</small>
|
||
<b>{paceSignalText}</b>
|
||
</span>
|
||
</div>
|
||
<span className={`scan-forecast-fit ${thresholdDecision.tone}`}>
|
||
{thresholdDecision.relation}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="scan-opportunity-expand"
|
||
aria-expanded={expanded}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
toggleRowAnalysis(row);
|
||
}}
|
||
>
|
||
<BarChart3 size={14} />
|
||
{expanded
|
||
? isEn
|
||
? "Hide analysis"
|
||
: "收起分析"
|
||
: isEn
|
||
? "AI analysis"
|
||
: "AI 分析"}
|
||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||
</button>
|
||
</div>
|
||
<div className={`scan-forecast-ai-line ${aiMeta?.tone || "neutral"}`}>
|
||
<b>{isEn ? "Current read" : "当前判断"}</b>
|
||
<small>{shortConclusion}</small>
|
||
</div>
|
||
{expanded ? (
|
||
<div className="scan-ai-analysis">
|
||
<div className="scan-ai-analysis-head">
|
||
<div>
|
||
<strong>{isEn ? "Conclusion" : "结论"}</strong>
|
||
<p>{thresholdDecision.headline}</p>
|
||
<small>{thresholdDecision.summary}</small>
|
||
</div>
|
||
<span className={`scan-ai-forecast-pill ${thresholdDecision.tone}`}>
|
||
{thresholdDecision.relation}
|
||
</span>
|
||
</div>
|
||
<div className="scan-ai-temperature-line">
|
||
<span>
|
||
<small>{isEn ? "Bucket" : "判断对象"}</small>
|
||
<b>{bucketLabel}</b>
|
||
</span>
|
||
<span>
|
||
<small>{isEn ? "AI forecast" : "AI 预测"}</small>
|
||
<b>
|
||
{cityForecast.predicted != null
|
||
? formatTemperatureValue(cityForecast.predicted, tempSymbol, { digits: 1 })
|
||
: "--"}
|
||
</b>
|
||
</span>
|
||
<span>
|
||
<small>{isEn ? "Forecast range" : "预测区间"}</small>
|
||
<b>{forecastRangeLabel}</b>
|
||
</span>
|
||
<span>
|
||
<small>{isEn ? "Confidence" : "信心"}</small>
|
||
<b>{cityForecast.confidence || thresholdDecision.confidence}</b>
|
||
</span>
|
||
</div>
|
||
<div className="scan-ai-evidence-line">
|
||
<span>
|
||
<small>DEB</small>
|
||
<b>{group.debLabel}</b>
|
||
</span>
|
||
<span>
|
||
<small>{modelSupportLabel}</small>
|
||
<b>{modelSupportText}</b>
|
||
</span>
|
||
<span>
|
||
<small>METAR</small>
|
||
<b>{metarConflictText}</b>
|
||
</span>
|
||
<span>
|
||
<small>{paceLabel}</small>
|
||
<b>{paceSignalText}</b>
|
||
</span>
|
||
</div>
|
||
<div className="scan-ai-brief-grid">
|
||
<section>
|
||
<strong>{isEn ? "Why" : "为什么"}</strong>
|
||
<ul>
|
||
{keyReasons.map((reason) => (
|
||
<li key={reason}>{reason}</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
<section>
|
||
<strong>{isEn ? "What can change it" : "可能改变判断的因素"}</strong>
|
||
<ul>
|
||
{riskItems.map((reason) => (
|
||
<li key={reason}>{reason}</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
</div>
|
||
<div className="scan-ai-airport-read">
|
||
{cityForecast.airportRead ? (
|
||
<p>{cityForecast.airportRead}</p>
|
||
) : null}
|
||
{cityForecast.weatherRead ? (
|
||
<p>{cityForecast.weatherRead}</p>
|
||
) : null}
|
||
{cityForecast.paceRead ? (
|
||
<p>{cityForecast.paceRead}</p>
|
||
) : null}
|
||
{cityForecast.peakWindow ? (
|
||
<p>{cityForecast.peakWindow}</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|