2026-04-23 22:48:57 +08:00
"use client" ;
2026-04-25 03:26:28 +08:00
import { BarChart3 , ChevronDown , ChevronUp } from "lucide-react" ;
2026-04-23 23:49:52 +08:00
import React from "react" ;
2026-04-23 22:48:57 +08:00
import { useI18n } from "@/hooks/useI18n" ;
2026-04-24 00:34:09 +08:00
import type {
2026-04-25 01:41:45 +08:00
CityDetail ,
2026-04-24 00:34:09 +08:00
ScanOpportunityRow ,
} from "@/lib/dashboard-types" ;
2026-04-23 23:49:52 +08:00
import { getLocalizedCityName } from "@/lib/dashboard-home-copy" ;
2026-04-24 02:14:20 +08:00
import {
formatTemperatureValue ,
2026-04-25 01:41:45 +08:00
getModelView ,
getProbabilityView ,
2026-04-26 00:24:11 +08:00
getTodayPaceView ,
2026-04-24 02:14:20 +08:00
normalizeTemperatureLabel ,
normalizeTemperatureSymbol ,
} from "@/lib/dashboard-utils" ;
2026-04-23 22:48:57 +08:00
2026-04-24 00:34:09 +08:00
type PhaseMeta = {
label : string ;
tone : "green" | "amber" | "blue" | "red" ;
};
2026-04-23 23:49:52 +08:00
function formatPercent ( value? : number | null , signed = false ) {
if ( value == null || Number . isNaN ( Number ( value ))) return "--" ;
const numeric = Number ( value );
2026-04-24 00:13:11 +08:00
return ` ${ signed && numeric >= 0 ? "+" : "" }${ numeric . toFixed ( 1 ) } %` ;
2026-04-23 23:49:52 +08:00
}
2026-04-25 01:41:45 +08:00
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 ));
}
2026-04-24 00:13:11 +08:00
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` ;
2026-04-23 23:49:52 +08:00
}
2026-04-24 00:13:11 +08:00
if ( hours <= 0 ) return `剩余 ${ remains } 分钟` ;
return `剩余 ${ hours } h ${ remains } m` ;
2026-04-23 23:49:52 +08:00
}
2026-04-25 06:42:24 +08:00
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` ;
}
2026-04-24 03:30:55 +08:00
function formatAction (
row : ScanOpportunityRow ,
locale : string ,
tempSymbol? : string | null ,
) {
2026-04-25 06:42:24 +08:00
return formatTradeSide ( row , locale , tempSymbol );
2026-04-23 23:49:52 +08:00
}
2026-04-24 00:34:09 +08:00
export function getWindowPhaseMeta (
row : Pick < ScanOpportunityRow , "window_phase" | "trend_alignment" >,
2026-04-24 00:13:11 +08:00
locale : string ,
2026-04-24 00:34:09 +08:00
) : PhaseMeta {
2026-04-24 00:13:11 +08:00
const mode = String ( row . window_phase || "" ). toLowerCase ();
2026-04-24 13:09:31 +08:00
if ( mode === "city_snapshot" ) {
return {
label : locale === "en-US" ? "City Snapshot" : "城市概况" ,
tone : "blue" ,
};
}
2026-04-24 00:34:09 +08:00
if ( mode === "active_peak" ) {
return {
label : locale === "en-US" ? "Peak Window" : "峰值窗口" ,
tone : "red" ,
};
}
2026-04-24 04:14:52 +08:00
if ( mode === "setup_today" ) {
2026-04-24 00:13:11 +08:00
return {
label : locale === "en-US" ? "Touch Play" : "触达博弈" ,
tone : "red" ,
};
}
2026-04-24 04:14:52 +08:00
if ( mode === "early_today" ) {
return {
label : locale === "en-US" ? "Early Today" : "日内早段" ,
tone : "blue" ,
};
}
2026-04-24 00:13:11 +08:00
if ( mode === "tomorrow" || mode === "week_ahead" ) {
return {
label : locale === "en-US" ? "Early" : "早期机会" ,
tone : "blue" ,
};
}
2026-04-24 00:34:09 +08:00
if ( mode === "post_peak" ) {
return {
label : locale === "en-US" ? "Post Peak" : "峰后确认" ,
tone : "amber" ,
};
}
2026-04-24 00:13:11 +08:00
if ( row . trend_alignment ) {
return {
label : locale === "en-US" ? "Trend" : "趋势确认" ,
tone : "amber" ,
};
}
return {
label : locale === "en-US" ? "Tradable" : "可交易" ,
tone : "green" ,
};
}
2026-04-24 14:41:33 +08:00
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$/ , "" ) } ¢` ;
}
2026-04-25 03:59:02 +08:00
function formatTradeSide (
row : ScanOpportunityRow ,
locale : string ,
tempSymbol? : string | null ,
) {
2026-04-25 03:26:28 +08:00
const side = String ( row . side || "" ). toLowerCase ();
2026-04-25 06:42:24 +08:00
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 } 桶` ;
2026-04-25 03:26:28 +08:00
if ( row . action ) {
2026-04-25 03:59:02 +08:00
return normalizeTemperatureLabel (
String ( row . action ). replace ( String ( row . target_label || "" ), "" ),
tempSymbol ,
)
2026-04-25 03:26:28 +08:00
. 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 "--" ;
}
2026-04-25 06:42:24 +08:00
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 未冲突" ;
}
2026-04-25 03:26:28 +08:00
function getOpportunityStrength ( edgePercent? : number | null , locale = "zh-CN" ) {
const edge = Number ( edgePercent );
const normalized = Number . isFinite ( edge ) ? edge : 0 ;
if ( normalized >= 20 ) {
return {
2026-04-25 06:52:17 +08:00
label : locale === "en-US" ? "High confidence" : "高胜率" ,
2026-04-25 03:26:28 +08:00
tone : "strong" ,
};
}
if ( normalized >= 10 ) {
return {
2026-04-25 06:52:17 +08:00
label : locale === "en-US" ? "Medium confidence" : "中等胜率" ,
2026-04-25 03:26:28 +08:00
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 }),
}));
}
2026-04-25 06:42:24 +08:00
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 } )}` ;
}
2026-04-25 03:26:28 +08:00
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 ,
2026-04-25 06:42:24 +08:00
_edgePercent? : number | null ,
2026-04-25 03:26:28 +08:00
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" ) {
2026-04-25 06:42:24 +08:00
return ` ${ strengthLabel || "Watch" } : AI should validate against ${ modelBasis } .` ;
2026-04-25 03:26:28 +08:00
}
2026-04-25 06:42:24 +08:00
return ` ${ strengthLabel || "观察" } : AI 需结合${ modelBasis } 确认。` ;
2026-04-25 03:26:28 +08:00
}
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"
2026-04-25 06:42:24 +08:00
? `Wide spread ${ formatQuoteCents ( spread ) } may distort the displayed market price.`
: `盘口价差 ${ formatQuoteCents ( spread ) } 偏宽,可能扭曲市场价格参考。` ,
2026-04-25 03:26:28 +08:00
);
}
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"
2026-04-25 06:42:24 +08:00
? "Tail bucket was cluster-adjusted; bucket confidence may be overstated."
: "尾部桶已做模型集群折扣,温度桶信心可能偏乐观。" ,
2026-04-25 03:26:28 +08:00
);
}
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 ,
2026-04-25 06:42:24 +08:00
_edgePercent? : number | null ,
2026-04-25 03:26:28 +08:00
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 );
}
2026-04-25 06:42:24 +08:00
const modelBasis = getModelSourceSummary ( row , locale , row . target_unit || row . temp_symbol );
2026-04-25 03:26:28 +08:00
reasons . push (
locale === "en-US"
2026-04-25 06:42:24 +08:00
? `AI uses ${ modelBasis } with market ask ${ formatQuoteCents ( price ) } only as downstream bucket context.`
: `AI 以 ${ modelBasis } 为主,市场买价 ${ formatQuoteCents ( price ) } 只作下游温度桶参考。` ,
2026-04-25 03:26:28 +08:00
);
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"
2026-04-25 06:42:24 +08:00
? "AI did not classify this row as the primary forecast bucket."
: "AI 未把该合约列为主预测桶。" ),
2026-04-25 03:26:28 +08:00
];
}
if ( edgePercent != null && Number ( edgePercent ) < 10 ) {
return [
locale === "en-US"
2026-04-25 06:42:24 +08:00
? "This bucket is not the current forecast center."
: "该桶不是当前预测中枢。" ,
2026-04-25 03:26:28 +08:00
];
}
return [
locale === "en-US"
2026-04-25 06:42:24 +08:00
? "No hard veto in the current AI/rule snapshot."
: "当前 AI/规则快照没有硬性排除项。" ,
2026-04-25 03:26:28 +08:00
];
}
2026-04-24 23:32:32 +08:00
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 ,
};
}
2026-04-25 02:19:37 +08:00
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 ,
};
}
2026-04-24 23:32:32 +08:00
return null ;
}
2026-04-25 06:42:24 +08:00
type ObservationPoint = { time? : string ; temp? : number | null };
2026-04-24 00:34:09 +08:00
2026-04-25 06:42:24 +08:00
type V4TradeDecision = {
decision : "approve" | "downgrade" | "veto" | "watchlist" ;
label : string ;
tone : "approve" | "downgrade" | "veto" | "watchlist" ;
reason : string ;
metarSummary? : string | null ;
airportReport? : string | null ;
metarEvidence : string [];
};
2026-04-24 00:34:09 +08:00
2026-04-25 06:42:24 +08:00
type V4CityForecast = {
predicted : number | null ;
low : number | null ;
high : number | null ;
confidence? : string | null ;
peakWindow? : string | null ;
airportRead? : string | null ;
2026-04-26 00:24:11 +08:00
weatherRead? : string | null ;
paceRead? : string | null ;
paceTone ?: "warm" | "cold" | "neutral" | string | null ;
paceDelta? : number | null ;
paceAdjustedHigh? : number | null ;
2026-04-25 06:42:24 +08:00
reason? : string | null ;
modelNote? : string | null ;
source : "ai" | "fallback" ;
};
2026-04-24 06:31:29 +08:00
2026-04-25 02:41:38 +08:00
function normalizeLookupKey ( value? : string | null ) {
return String ( value || "" )
. trim ()
. toLowerCase ()
. replace ( /[\s_-]+/g , "" );
}
2026-04-25 01:41:45 +08:00
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 ]
2026-04-25 02:41:38 +08:00
. map ( normalizeLookupKey )
2026-04-25 01:41:45 +08:00
. filter ( Boolean );
return (
Object . entries ( cityDetailsByName ). find (([ name , detail ]) => {
const detailKeys = [ name , detail . name , detail . display_name ]
2026-04-25 02:41:38 +08:00
. map ( normalizeLookupKey )
2026-04-25 01:41:45 +08:00
. filter ( Boolean );
return rowKeys . some (( key ) => detailKeys . includes ( key ));
}) ? .[ 1 ] || null
);
}
2026-04-25 02:41:38 +08:00
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 ]),
);
}
2026-04-25 06:42:24 +08:00
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 "" ;
}
2026-04-26 00:24:11 +08:00
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" : "暂未看到明确天气压温信号" ) } 。` ;
}
2026-04-25 06:42:24 +08:00
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 );
2026-04-26 00:24:11 +08:00
const decodedWeather =
decodeMetarWeatherToken ( weather , locale ) ||
weather ||
decodeRawMetarWeather ( rawMetar , locale );
2026-04-25 06:42:24 +08:00
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 } ` );
2026-04-26 00:24:11 +08:00
if ( decodedWeather ) parts . push ( isEn ? `weather ${ decodedWeather } ` : `天气 ${ decodedWeather } ` );
2026-04-25 06:42:24 +08:00
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 ( ", " ) } 。` ;
}
2026-04-26 00:24:11 +08:00
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 路径。" ;
}
2026-04-25 06:42:24 +08:00
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 );
2026-04-26 00:24:11 +08:00
const weatherRead = formatAirportWeatherRead ( row , detail , locale );
const paceRead = getPaceDeviationRead ( detail , locale , tempSymbol );
2026-04-25 06:42:24 +08:00
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
2026-04-26 00:24:11 +08:00
? ` ${ 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 预测区间。`
2026-04-25 06:42:24 +08:00
: null );
return {
predicted : fallbackPredicted ,
low : fallbackLow ,
high : fallbackHigh ,
confidence : row.ai_forecast_confidence || row . ai_city_confidence ,
peakWindow ,
airportRead ,
2026-04-26 00:24:11 +08:00
weatherRead ,
paceRead : paceRead?.read || null ,
paceTone : paceRead?.tone || null ,
paceDelta : paceRead?.delta ?? null ,
paceAdjustedHigh : paceRead?.adjustedHigh ?? null ,
2026-04-25 06:42:24 +08:00
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 );
2026-04-26 00:24:11 +08:00
const airportWeatherRead = formatAirportWeatherRead ( row , detail , locale );
2026-04-25 06:42:24 +08:00
if ( peakTiming ) evidence . push ( peakTiming );
if ( airportReport ) evidence . push ( airportReport );
2026-04-26 00:24:11 +08:00
if ( airportWeatherRead ) evidence . push ( airportWeatherRead );
2026-04-25 06:42:24 +08:00
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 || [],
};
}
2026-04-25 02:41:38 +08:00
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 ;
}
2026-04-25 01:41:45 +08:00
function getDetailBucketEventProbability (
detail : CityDetail | null ,
row : ScanOpportunityRow ,
tempSymbol? : string | null ,
) {
if ( ! detail ) return null ;
2026-04-25 02:41:38 +08:00
const view = getProbabilityView ( detail , getDetailViewDate ( detail , row ));
2026-04-25 01:41:45 +08:00
const buckets = Array . isArray ( view . probabilitiesAll )
? view . probabilitiesAll
: [];
if ( ! buckets . length ) return null ;
2026-04-25 02:41:38 +08:00
const matched = buckets . find (( bucket ) => bucketMatchesRow ( bucket , row , tempSymbol ));
2026-04-25 01:41:45 +08:00
return normalizeProbability ( matched ? . probability );
}
2026-04-24 14:41:33 +08:00
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 [];
};
2026-04-25 01:41:45 +08:00
function buildOpportunityGroups (
rows : ScanOpportunityRow [],
locale : string ,
cityDetailsByName? : Record < string , CityDetail >,
) : OpportunityGroup [] {
2026-04-24 14:41:33 +08:00
const groups = new Map < string , OpportunityGroup >();
for ( const row of rows ) {
const tempSymbol = normalizeTemperatureSymbol ( row . target_unit || row . temp_symbol );
2026-04-25 01:41:45 +08:00
const detail = getDetailForRow ( row , cityDetailsByName );
2026-04-24 14:41:33 +08:00
const cityName = getLocalizedCityName (
row . city ,
row . city_display_name || row . display_name || row . city ,
locale ,
);
2026-04-25 02:41:38 +08:00
const date = detail ? getDetailViewDate ( detail , row ) : row . selected_date || row . local_date || "" ;
2026-04-24 14:41:33 +08:00
const key = ` ${ row . city || cityName } | ${ date } ` ;
2026-04-25 01:41:45 +08:00
const modelView = detail ? getModelView ( detail , date ) : null ;
const debPrediction = modelView ? . deb ?? row . deb_prediction ?? null ;
2026-04-25 06:42:24 +08:00
const modelClusterLabel = formatModelClusterRange (
modelView ? . models || row . model_cluster_sources ,
tempSymbol ,
);
2026-04-24 14:41:33 +08:00
const existing = groups . get ( key );
if ( ! existing ) {
groups . set ( key , {
key ,
cityName ,
date ,
tempSymbol ,
debLabel :
2026-04-25 01:41:45 +08:00
debPrediction != null
? formatTemperatureValue ( Number ( debPrediction ), tempSymbol , { digits : 1 })
2026-04-24 14:41:33 +08:00
: "--" ,
2026-04-25 06:42:24 +08:00
peakLabel : modelClusterLabel ,
peakProbability : null ,
2026-04-24 14:41:33 +08:00
phaseMeta : getWindowPhaseMeta ( row , locale ),
localTime : row.local_time ,
remainingMinutes : row.remaining_window_minutes ,
rows : [ row ],
});
continue ;
}
existing . rows . push ( row );
2026-04-25 06:42:24 +08:00
if ( existing . peakLabel === "--" && modelClusterLabel !== "--" ) {
existing . peakLabel = modelClusterLabel ;
2026-04-24 14:41:33 +08:00
}
}
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 ),
),
}));
2026-04-23 23:49:52 +08:00
}
2026-04-26 00:24:11 +08:00
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 );
}
2026-04-24 05:39:14 +08:00
export const OpportunityTable = React . memo ( function OpportunityTable ({
2026-04-23 23:49:52 +08:00
rows ,
2026-04-24 10:19:16 +08:00
status ,
stale ,
staleReason ,
loading ,
2026-04-23 23:49:52 +08:00
selectedRowId ,
onSelectRow ,
2026-04-25 01:41:45 +08:00
cityDetailsByName ,
2026-04-23 23:49:52 +08:00
} : {
rows : ScanOpportunityRow [];
2026-04-24 10:19:16 +08:00
status? : string | null ;
stale? : boolean ;
staleReason? : string | null ;
loading? : boolean ;
2026-04-23 23:49:52 +08:00
selectedRowId? : string | null ;
onSelectRow ?: ( row : ScanOpportunityRow ) => void ;
2026-04-25 01:41:45 +08:00
cityDetailsByName? : Record < string , CityDetail >;
2026-04-23 23:49:52 +08:00
}) {
2026-04-23 22:48:57 +08:00
const { locale } = useI18n ();
const isEn = locale === "en-US" ;
2026-04-24 10:19:16 +08:00
const hasRows = rows . length > 0 ;
2026-04-24 12:46:44 +08:00
const scanInProgress =
loading || status === "partial" || status === "scanning" ;
2026-04-24 14:41:33 +08:00
const groups = React . useMemo (
2026-04-25 01:41:45 +08:00
() => buildOpportunityGroups ( rows , locale , cityDetailsByName ),
[ rows , locale , cityDetailsByName ],
2026-04-24 14:41:33 +08:00
);
2026-04-25 03:26:28 +08:00
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 ;
});
}, []);
2026-04-23 22:48:57 +08:00
2026-04-25 06:42:24 +08:00
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 ],
);
2026-04-24 10:19:16 +08:00
if ( ! hasRows ) {
const title =
2026-04-24 12:46:44 +08:00
scanInProgress
2026-04-24 10:19:16 +08:00
? isEn
? "Scanning markets"
: "正在扫描市场"
: status === "failed"
? isEn
? "Scan failed"
: "扫描失败"
: isEn
? "No tradable market right now"
: "当前暂无可交易市场" ;
const copy =
2026-04-24 12:46:44 +08:00
scanInProgress
2026-04-24 10:19:16 +08:00
? 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."
: "当前快照里还没有可交易的主信号。" ;
2026-04-24 00:13:11 +08:00
return (
< div className = "scan-table-shell empty" >
< div className = "scan-empty-state" >
2026-04-24 10:19:16 +08:00
< div className = "scan-empty-title" >{ title }</ div >
< div className = "scan-empty-copy" >{ copy }</ div >
2026-04-23 23:49:52 +08:00
</ div >
2026-04-24 00:13:11 +08:00
</ div >
);
}
return (
< div className = "scan-table-shell" >
2026-04-24 10:19:16 +08:00
{ 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 }
2026-04-26 00:24:11 +08:00
< div className = "scan-table-body scan-opportunity-groups scan-forecast-desk" >
2026-04-25 01:41:45 +08:00
{ groups . map (( group ) => {
const groupSelected = group . rows . some (( row ) => row . id === selectedRowId );
2026-04-26 00:24:11 +08:00
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 ;
2026-04-25 01:41:45 +08:00
return (
< section
key = { group . key }
2026-04-26 00:24:11 +08:00
className = { `scan-opportunity-group scan-forecast-city-card ${ groupSelected ? "selected" : "" } ` }
2026-04-25 01:41:45 +08:00
>
2026-04-25 00:08:21 +08:00
< button
type = "button"
2026-04-26 00:24:11 +08:00
className = "scan-opportunity-group-head scan-forecast-city-head"
2026-04-25 00:08:21 +08:00
onClick = {() => {
const firstRow = group . rows [ 0 ];
2026-04-25 06:42:24 +08:00
if ( firstRow ) selectAndOpenRow ( firstRow );
2026-04-25 00:08:21 +08:00
}}
>
2026-04-26 00:24:11 +08:00
< div className = "scan-forecast-city-title" >
< span className = "scan-forecast-kicker" >
{ isEn ? "City max-temp read" : "城市最高温判断" }
</ span >
2026-04-24 14:41:33 +08:00
< strong >{ group . cityName }</ strong >
2026-04-26 00:24:11 +08:00
< 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 >
2026-04-24 15:00:02 +08:00
</ div >
2026-04-24 00:13:11 +08:00
</ div >
2026-04-26 00:24:11 +08:00
< div className = "scan-forecast-city-read" >
< small >{ isEn ? "AI expected high" : "AI 预计最高温" }</ small >
< b >{ groupForecastLabel }</ b >
< span >{ isEn ? "Range" : "区间" } { groupRangeLabel }</ span >
2026-04-24 14:41:33 +08:00
< b className = { `scan-phase-badge ${ group . phaseMeta . tone } ` }>
{ group . phaseMeta . label }
</ b >
2026-04-24 00:13:11 +08:00
</ div >
2026-04-25 00:08:21 +08:00
</ button >
2026-04-24 00:13:11 +08:00
2026-04-24 14:41:33 +08:00
< div className = "scan-opportunity-items" >
2026-04-25 03:26:28 +08:00
{ group . rows . map (( row ) => {
2026-04-24 14:41:33 +08:00
const tempSymbol = normalizeTemperatureSymbol ( row . target_unit || row . temp_symbol );
2026-04-25 01:41:45 +08:00
const detail = getDetailForRow ( row , cityDetailsByName );
2026-04-25 06:42:24 +08:00
const debDistanceLabel = isEn ? "DEB distance" : "DEB 距离" ;
const modelSupportLabel = isEn ? "Model support" : "模型支持" ;
const metarLabel = "METAR" ;
2026-04-26 00:24:11 +08:00
const paceLabel = isEn ? "Path vs DEB" : "路径偏差" ;
2026-04-25 06:42:24 +08:00
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 ,
);
2026-04-26 00:24:11 +08:00
const paceSignalText = getPaceSignalLabel ( cityForecast , locale , tempSymbol );
const aiMeta = getAiMeta ( row , locale );
const thresholdDecision = getThresholdDecision (
2026-04-25 06:42:24 +08:00
row ,
cityForecast ,
locale ,
tempSymbol ,
);
2026-04-25 03:26:28 +08:00
const expanded = expandedRowIds . has ( row . id );
2026-04-25 06:42:24 +08:00
const shortConclusion =
2026-04-26 00:24:11 +08:00
` ${ 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 );
2026-04-24 14:41:33 +08:00
return (
2026-04-25 01:41:45 +08:00
< div
2026-04-24 14:41:33 +08:00
key = { row . id }
2026-04-26 00:24:11 +08:00
className = { `scan-opportunity-item scan-forecast-row ${ selectedRowId === row . id ? "selected" : "" } ${ expanded ? "expanded" : "" } ai-fit- ${ thresholdDecision . tone } ${ aiMeta ? `ai- ${ aiMeta . tone } ` : "" } ` }
2026-04-25 06:42:24 +08:00
onClick = {() => selectAndOpenRow ( row )}
2026-04-24 14:41:33 +08:00
>
2026-04-26 00:24:11 +08:00
< 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 >
2026-04-25 06:42:24 +08:00
< small >{ debDistanceLabel }</ small >
< b >{ debDistanceText }</ b >
</ span >
2026-04-26 00:24:11 +08:00
< span >
2026-04-25 06:42:24 +08:00
< small >{ modelSupportLabel }</ small >
< b >{ modelSupportText }</ b >
</ span >
2026-04-26 00:24:11 +08:00
< span >
2026-04-25 06:42:24 +08:00
< small >{ metarLabel }</ small >
< b >{ metarConflictText }</ b >
</ span >
2026-04-26 00:24:11 +08:00
< span >
< small >{ paceLabel }</ small >
< b >{ paceSignalText }</ b >
</ span >
2026-04-25 06:42:24 +08:00
</ div >
2026-04-26 00:24:11 +08:00
< span className = { `scan-forecast-fit ${ thresholdDecision . tone } ` }>
{ thresholdDecision . relation }
2026-04-25 06:42:24 +08:00
</ 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
2026-04-26 00:24:11 +08:00
? "AI analysis"
: "AI 分析" }
2026-04-25 06:42:24 +08:00
{ expanded ? < ChevronUp size = { 14 } /> : < ChevronDown size = { 14 } />}
</ button >
</ div >
2026-04-26 00:24:11 +08:00
< div className = { `scan-forecast-ai-line ${ aiMeta ? . tone || "neutral" } ` }>
< b >{ isEn ? "Current read" : "当前判断" }</ b >
2026-04-25 03:26:28 +08:00
< small >{ shortConclusion }</ small >
2026-04-25 06:42:24 +08:00
</ div >
2026-04-25 03:26:28 +08:00
{ expanded ? (
2026-04-26 00:24:11 +08:00
< div className = "scan-ai-analysis" >
< div className = "scan-ai-analysis-head" >
2026-04-25 03:26:28 +08:00
< div >
2026-04-26 00:24:11 +08:00
< strong >{ isEn ? "Conclusion" : "结论" }</ strong >
< p >{ thresholdDecision . headline }</ p >
< small >{ thresholdDecision . summary }</ small >
2026-04-25 03:26:28 +08:00
</ div >
2026-04-26 00:24:11 +08:00
< span className = { `scan-ai-forecast-pill ${ thresholdDecision . tone } ` }>
{ thresholdDecision . relation }
2026-04-25 06:42:24 +08:00
</ span >
</ div >
2026-04-26 00:24:11 +08:00
< div className = "scan-ai-temperature-line" >
2026-04-25 06:42:24 +08:00
< span >
2026-04-26 00:24:11 +08:00
< small >{ isEn ? "Bucket" : "判断对象" }</ small >
< b >{ bucketLabel }</ b >
</ span >
< span >
< small >{ isEn ? "AI forecast" : "AI 预测" }</ small >
< b >
2026-04-25 06:42:24 +08:00
{ cityForecast . predicted != null
? formatTemperatureValue ( cityForecast . predicted , tempSymbol , { digits : 1 })
: "--" }
2026-04-26 00:24:11 +08:00
</ b >
</ span >
< span >
< small >{ isEn ? "Forecast range" : "预测区间" }</ small >
< b >{ forecastRangeLabel }</ b >
</ span >
< span >
< small >{ isEn ? "Confidence" : "信心" }</ small >
< b >{ cityForecast . confidence || thresholdDecision . confidence }</ b >
2026-04-25 06:42:24 +08:00
</ span >
</ div >
2026-04-26 00:24:11 +08:00
< 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" >
2026-04-25 06:42:24 +08:00
< section >
2026-04-26 00:24:11 +08:00
< strong >{ isEn ? "Why" : "为什么" }</ strong >
2026-04-25 06:42:24 +08:00
< ul >
{ keyReasons . map (( reason ) => (
< li key = { reason }>{ reason }</ li >
))}
</ ul >
</ section >
< section >
2026-04-26 00:24:11 +08:00
< strong >{ isEn ? "What can change it" : "可能改变判断的因素" }</ strong >
2026-04-25 06:42:24 +08:00
< ul >
{ riskItems . map (( reason ) => (
< li key = { reason }>{ reason }</ li >
))}
</ ul >
</ section >
</ div >
2026-04-26 00:24:11 +08:00
< 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 >
2026-04-25 03:26:28 +08:00
</ div >
2026-04-24 23:32:32 +08:00
) : null }
2026-04-25 01:41:45 +08:00
</ div >
2026-04-24 14:41:33 +08:00
);
})}
</ div >
</ section >
2026-04-25 01:41:45 +08:00
);
})}
2026-04-24 00:13:11 +08:00
</ div >
2026-04-23 22:48:57 +08:00
</ div >
);
2026-04-24 05:39:14 +08:00
});