2026-03-09 10:36:03 +08:00
"use client" ;
2026-03-18 20:38:43 +08:00
import type { ChartConfiguration } from "chart.js" ;
2026-03-09 10:36:03 +08:00
import clsx from "clsx" ;
2026-03-18 20:38:43 +08:00
import { startTransition , useMemo } from "react" ;
2026-03-09 10:36:03 +08:00
import { useChart } from "@/hooks/useChart" ;
import { useCityData , useDashboardStore } from "@/hooks/useDashboardStore" ;
2026-03-10 04:45:40 +08:00
import { useI18n } from "@/hooks/useI18n" ;
2026-03-10 09:02:56 +08:00
import {
CityDetail ,
MarketScan ,
MarketTopBucket ,
ProbabilityBucket ,
} from "@/lib/dashboard-types" ;
2026-03-09 10:36:03 +08:00
import {
getHeroMetaItems ,
getModelView ,
getProbabilityView ,
2026-03-10 04:45:40 +08:00
getRiskBadgeLabel ,
2026-03-09 10:36:03 +08:00
getTemperatureChartData ,
getWeatherSummary ,
} from "@/lib/dashboard-utils" ;
function EmptyState ({ text } : { text : string }) {
2026-03-10 09:02:56 +08:00
return (
< div style = {{ color : "var(--text-muted)" , fontSize : "13px" }}>{ text }</ div >
);
}
function toPercent ( value? : number | null ) {
2026-03-11 11:37:07 +08:00
if ( value == null ) return null ;
2026-03-10 09:02:56 +08:00
const numeric = Number ( value );
if ( ! Number . isFinite ( numeric )) return null ;
return ` ${ ( numeric * 100 ). toFixed ( 1 ) } %` ;
}
function toPriceCents ( value? : number | null ) {
2026-03-11 11:37:07 +08:00
if ( value == null ) return null ;
2026-03-10 09:02:56 +08:00
const numeric = Number ( value );
if ( ! Number . isFinite ( numeric )) return null ;
const normalized = numeric > 1 ? numeric / 100 : numeric ;
const cents = normalized * 100 ;
const rounded = Math . round ( cents * 10 ) / 10 ;
const text = Number . isInteger ( rounded )
? String ( rounded . toFixed ( 0 ))
: String ( rounded );
return ` ${ text } c` ;
}
function parseTempFromText ( value : unknown ) {
const text = String ( value || "" );
const match = text . match ( /(-?\d+(?:\.\d+)?)/ );
if ( ! match ) return null ;
const numeric = Number ( match [ 1 ]);
return Number . isFinite ( numeric ) ? numeric : null ;
}
function getBucketTemp ( bucket : ProbabilityBucket ) {
2026-03-11 11:37:07 +08:00
if ( bucket . value != null ) {
const byValue = Number ( bucket . value );
if ( Number . isFinite ( byValue )) return byValue ;
}
2026-03-10 09:02:56 +08:00
return parseTempFromText ( bucket . label || bucket . bucket || bucket . range );
}
function getMarketYesPrice ( scan? : MarketScan | null ) {
2026-03-11 11:37:07 +08:00
if ( scan ? . market_price != null ) {
const preferred = Number ( scan . market_price );
if ( Number . isFinite ( preferred )) return preferred ;
}
if ( scan ? . yes_token ? . implied_probability != null ) {
const implied = Number ( scan . yes_token . implied_probability );
if ( Number . isFinite ( implied )) return implied ;
}
return null ;
2026-03-10 09:02:56 +08:00
}
2026-04-22 00:09:49 +08:00
function isFahrenheitSymbol ( symbol ?: string | null ) {
return String ( symbol || "" ). toUpperCase (). includes ( "F" );
}
function displayTempToMarketCelsius (
value : number | null ,
detail : Pick < CityDetail , "temp_symbol" >,
) {
if ( value == null || ! Number . isFinite ( value )) return null ;
if ( isFahrenheitSymbol ( detail . temp_symbol )) {
return (( value - 32 ) * 5 ) / 9 ;
}
return value ;
}
2026-04-22 00:53:58 +08:00
function formatBucketDisplayLabel (
bucket : ProbabilityBucket ,
detail : Pick < CityDetail , "temp_symbol" >,
) {
let bucketLabel = bucket . label || ` ${ bucket . value }${ detail . temp_symbol } ` ;
if ( ! bucketLabel ) return "" ;
let str = String ( bucketLabel ). toUpperCase (). replace ( /\s+/g , "" );
const symbol = detail . temp_symbol || "°C" ;
if ( isFahrenheitSymbol ( symbol )) {
str = str . replace ( /℃/g , "°F" ). replace ( /°C/g , "°F" );
} else {
str = str . replace ( /℃/g , "°C" ). replace ( /°F/g , "°C" );
}
str = str . replace ( /°?C($|\+|-)/g , "°C$1" );
str = str . replace ( /°?F($|\+|-)/g , "°F$1" );
if ( ! /[°℃][CF]/ . test ( str ) && /[0-9]/ . test ( str )) {
str += symbol ;
}
return str ;
}
function getMarketBucketUnit ( bucket? : MarketTopBucket | null ) {
return String ( bucket ? . unit || "" ). toUpperCase ();
}
function isMarketBucketAbove ( bucket? : MarketTopBucket | null ) {
const text = ` ${ bucket ? . label || "" } ${ bucket ? . slug || "" } ${ bucket ? . question || "" } `
. toLowerCase ()
. replace ( /\s+/g , "" );
return text . includes ( "+" ) || text . includes ( "orhigher" ) || text . includes ( "or-higher" );
}
function isMarketBucketBelow ( bucket? : MarketTopBucket | null ) {
const text = ` ${ bucket ? . label || "" } ${ bucket ? . slug || "" } ${ bucket ? . question || "" } `
. toLowerCase ()
. replace ( /\s+/g , "" );
return text . includes ( "<=" ) || text . includes ( "orlower" ) || text . includes ( "or-lower" );
}
2026-04-22 00:09:49 +08:00
function findMarketBucketForDisplayTemp (
buckets : MarketTopBucket [],
displayTemp : number | null ,
detail : Pick < CityDetail , "temp_symbol" >,
) {
2026-04-22 00:38:19 +08:00
if ( displayTemp == null || ! Number . isFinite ( displayTemp )) return null ;
2026-04-22 00:09:49 +08:00
let best : MarketTopBucket | null = null ;
let bestDelta = Number . POSITIVE_INFINITY ;
for ( const bucket of buckets ) {
2026-04-22 00:38:19 +08:00
const bucketUnit = String ( bucket . unit || "" ). toUpperCase ();
const compareTemp =
bucketUnit === "F"
? displayTemp
: displayTempToMarketCelsius ( displayTemp , detail );
if ( compareTemp == null ) continue ;
const lower = bucket . lower != null ? Number ( bucket . lower ) : null ;
const upper = bucket . upper != null ? Number ( bucket . upper ) : null ;
if (
lower != null &&
upper != null &&
Number . isFinite ( lower ) &&
Number . isFinite ( upper ) &&
compareTemp >= lower - 0.01 &&
compareTemp <= upper + 0.01
) {
return bucket ;
}
2026-04-22 00:09:49 +08:00
const rawTemp = bucket . temp ?? bucket . value ?? null ;
if ( rawTemp == null ) continue ;
const candidateTemp = Number ( rawTemp );
if ( ! Number . isFinite ( candidateTemp )) continue ;
2026-04-22 00:38:19 +08:00
const delta = Math . abs ( candidateTemp - compareTemp );
2026-04-22 00:09:49 +08:00
if ( delta < bestDelta ) {
best = bucket ;
bestDelta = delta ;
}
}
2026-04-22 00:38:19 +08:00
const tolerance = isFahrenheitSymbol ( detail . temp_symbol ) ? 0.56 : 0.26 ;
2026-04-22 00:09:49 +08:00
return best && bestDelta <= tolerance ? best : null ;
}
2026-04-22 00:53:58 +08:00
function marketBucketContainsDisplayTemp (
bucket : MarketTopBucket | null ,
displayTemp : number | null ,
detail : Pick < CityDetail , "temp_symbol" >,
) {
if ( ! bucket || displayTemp == null || ! Number . isFinite ( displayTemp )) return false ;
const bucketUnit = getMarketBucketUnit ( bucket );
const compareTemp =
bucketUnit === "F" ? displayTemp : displayTempToMarketCelsius ( displayTemp , detail );
if ( compareTemp == null ) return false ;
const lower = bucket . lower != null ? Number ( bucket . lower ) : null ;
const upper = bucket . upper != null ? Number ( bucket . upper ) : null ;
if ( lower != null && ! Number . isFinite ( lower )) return false ;
if ( upper != null && ! Number . isFinite ( upper )) return false ;
if ( lower != null && upper != null ) {
return compareTemp >= lower - 0.01 && compareTemp <= upper + 0.01 ;
}
if ( lower != null && isMarketBucketAbove ( bucket )) {
return compareTemp >= lower - 0.01 ;
}
if ( lower != null && isMarketBucketBelow ( bucket )) {
return compareTemp <= lower + 0.01 ;
}
const reference = bucket . temp ?? bucket . value ?? lower ;
const numeric = reference != null ? Number ( reference ) : null ;
if ( numeric == null || ! Number . isFinite ( numeric )) return false ;
const tolerance = bucketUnit === "F" ? 0.56 : 0.26 ;
return Math . abs ( compareTemp - numeric ) <= tolerance ;
}
function getAggregatedModelProbabilityForMarketBucket (
probabilities : ProbabilityBucket [],
bucket : MarketTopBucket | null ,
detail : Pick < CityDetail , "temp_symbol" >,
) {
if ( ! bucket ) return null ;
let total = 0 ;
let matched = 0 ;
for ( const probabilityBucket of probabilities ) {
const temp = getBucketTemp ( probabilityBucket );
if ( ! marketBucketContainsDisplayTemp ( bucket , temp , detail )) continue ;
const probability = Number ( probabilityBucket . probability );
if ( ! Number . isFinite ( probability )) continue ;
total += probability ;
matched += 1 ;
}
return matched > 0 ? Math . max ( 0 , Math . min ( 1 , total )) : null ;
}
2026-04-22 01:09:22 +08:00
type ProbabilityDisplayRow = {
key : string ;
label : string ;
probability : number ;
marketBucket? : MarketTopBucket | null ;
};
function formatMarketBucketDisplayLabel (
bucket : MarketTopBucket ,
detail : Pick < CityDetail , "temp_symbol" >,
) {
const label = String ( bucket . label || "" ). trim ();
if ( label ) {
const unit = getMarketBucketUnit ( bucket );
let normalized = label . toUpperCase (). replace ( /\s+/g , "" );
if ( unit === "F" || isFahrenheitSymbol ( detail . temp_symbol )) {
normalized = normalized
. replace ( /ORHIGHER/g , "+" )
. replace ( /ORLOWER/g , "-" )
. replace ( /℃/g , "°F" )
. replace ( /°C/g , "°F" )
. replace ( /(?<=\d)F/g , "°F" );
} else {
normalized = normalized
. replace ( /ORHIGHER/g , "+" )
. replace ( /ORLOWER/g , "-" )
. replace ( /℃/g , "°C" )
. replace ( /°F/g , "°C" )
. replace ( /(?<=\d)C/g , "°C" );
}
return normalized . replace ( /\+/g , "+" );
}
const unit =
getMarketBucketUnit ( bucket ) === "F" || isFahrenheitSymbol ( detail . temp_symbol )
? "°F"
: "°C" ;
const lower = bucket . lower != null ? Number ( bucket . lower ) : null ;
const upper = bucket . upper != null ? Number ( bucket . upper ) : null ;
if ( lower != null && upper != null && Number . isFinite ( lower ) && Number . isFinite ( upper )) {
return ` ${ lower } - ${ upper }${ unit } ` ;
}
const value = bucket . value ?? bucket . temp ?? lower ;
const numeric = value != null ? Number ( value ) : null ;
if ( numeric != null && Number . isFinite ( numeric )) {
return isMarketBucketAbove ( bucket ) ? ` ${ numeric }${ unit } +` : ` ${ numeric }${ unit } ` ;
}
return "--" ;
}
2026-04-17 00:35:48 +08:00
type ModelMetadata = NonNullable <
NonNullable < CityDetail [ "source_forecasts" ] >[ "open_meteo_multi_model" ]
> [ "model_metadata" ];
function getModelGroupMeta (
name : string ,
metadata : ModelMetadata ,
locale : string ,
) {
const meta = metadata ? .[ name ] || {};
const tier = String ( meta . tier || "" ). toLowerCase ();
const upperName = String ( name || "" ). toUpperCase ();
2026-04-17 00:59:10 +08:00
if ( tier . includes ( "aifs" ) || upperName . includes ( "AIFS" )) {
2026-04-17 00:35:48 +08:00
return {
2026-04-17 00:59:10 +08:00
key : "aifs" ,
label : locale === "en-US" ? "AIFS model" : "AIFS 模型" ,
2026-04-17 00:35:48 +08:00
order : 1 ,
tone : "blue" ,
};
}
if (
tier . includes ( "europe" ) ||
upperName . includes ( "ICON-EU" ) ||
upperName . includes ( "ICON-D2" )
) {
return {
key : "europe" ,
label : locale === "en-US" ? "Europe high-resolution" : "欧洲高分辨率" ,
order : 2 ,
tone : "cyan" ,
};
}
if (
tier . includes ( "north_america" ) ||
upperName === "RDPS" ||
upperName === "HRDPS"
) {
return {
key : "north-america" ,
label : locale === "en-US" ? "North America high-resolution" : "北美高分辨率" ,
order : 3 ,
tone : "amber" ,
};
}
return {
key : "global" ,
label : locale === "en-US" ? "Global baseline" : "全球基准" ,
order : 0 ,
tone : "neutral" ,
};
}
function formatModelMetaLine (
name : string ,
metadata : ModelMetadata ,
locale : string ,
) {
const meta = metadata ? .[ name ] || {};
const provider = String ( meta . provider || "" ). trim ();
const model = String ( meta . model || "" ). trim ();
const horizon = String ( meta . horizon || "" ). trim ();
const resolution = Number ( meta . resolution_km );
const parts = [
provider ,
model && model !== name ? model : "" ,
Number . isFinite ( resolution )
? ` ${ resolution }${ locale === "en-US" ? " km" : " 公里" } `
: "" ,
horizon ,
]. filter ( Boolean );
return parts . join ( " · " );
}
2026-04-17 00:48:12 +08:00
function normalizeModelNameForVote ( name : string ) {
return String ( name || "" )
. trim ()
. toLowerCase ()
. replace ( /[\s_/-]/g , "" );
}
function getModelVoteFamily ( name : string ) {
const normalized = normalizeModelNameForVote ( name );
if ([ "icon" , "iconeu" , "icond2" ]. includes ( normalized )) return "dwd_icon" ;
if ([ "gem" , "gdps" , "rdps" , "hrdps" ]. includes ( normalized )) return "eccc_gem" ;
if ([ "ecmwfaifs" , "aifs" ]. includes ( normalized )) return "ecmwf_aifs" ;
if ( normalized === "ecmwf" ) return "ecmwf_ifs" ;
return normalized || name ;
}
function getModelVotePriority ( name : string ) {
const normalized = normalizeModelNameForVote ( name );
return (
{
icond2 : 40 ,
iconeu : 30 ,
icon : 20 ,
hrdps : 40 ,
rdps : 35 ,
gdps : 30 ,
gem : 20 ,
ecmwfaifs : 30 ,
ecmwf : 30 ,
gfs : 30 ,
jma : 30 ,
mgm : 45 ,
nws : 45 ,
openmeteo : 15 ,
}[ normalized ] || 10
);
}
function getRoundedModelVoteDistribution (
detail : CityDetail ,
targetDate? : string | null ,
) {
const view = getModelView ( detail , targetDate );
const representatives = new Map <
string ,
{ name : string ; priority : number ; value : number }
> ();
Object . entries ( view . models || {}). forEach (([ name , rawValue ]) => {
const normalized = normalizeModelNameForVote ( name );
if ( normalized === "lgbm" || normalized . includes ( "meteoblue" )) return ;
const value = Number ( rawValue );
if ( ! Number . isFinite ( value )) return ;
const family = getModelVoteFamily ( name );
const priority = getModelVotePriority ( name );
const current = representatives . get ( family );
if ( ! current || priority > current . priority ) {
representatives . set ( family , { name , priority , value });
}
});
const bucketMap = new Map < number , { count : number ; models : string [] }>();
representatives . forEach (({ name , value }) => {
const rounded = Math . round ( value );
const row = bucketMap . get ( rounded ) || { count : 0 , models : [] };
row . count += 1 ;
row . models . push ( name );
bucketMap . set ( rounded , row );
});
const total = representatives . size ;
const rows = Array . from ( bucketMap . entries ())
. map (([ value , row ]) => ({
count : row.count ,
models : row.models ,
percent : total > 0 ? row . count / total : 0 ,
value ,
}))
. sort (( a , b ) => b . count - a . count || b . value - a . value );
return {
rows ,
total ,
};
}
2026-03-10 09:02:56 +08:00
function normalizeMarketProbability ( value? : number | null ) {
2026-03-11 11:37:07 +08:00
if ( value == null ) return null ;
2026-03-10 09:02:56 +08:00
const numeric = Number ( value );
if ( ! Number . isFinite ( numeric )) return null ;
if ( numeric > 1 ) return Math . max ( 0 , Math . min ( 1 , numeric / 100 ));
return Math . max ( 0 , Math . min ( 1 , numeric ));
}
2026-04-21 21:06:48 +08:00
function normalizeSignedProbability ( value? : number | null ) {
if ( value == null ) return null ;
const numeric = Number ( value );
if ( ! Number . isFinite ( numeric )) return null ;
if ( Math . abs ( numeric ) > 1 ) return numeric / 100 ;
return numeric ;
}
function formatSignedPercent ( value? : number | null , digits = 1 ) {
const normalized = normalizeSignedProbability ( value );
if ( normalized == null ) return "--" ;
const percent = normalized * 100 ;
const sign = percent > 0 ? "+" : "" ;
return ` ${ sign }${ percent . toFixed ( digits ) } %` ;
}
2026-03-10 09:02:56 +08:00
function getMarketTopBuckets ( scan? : MarketScan | null ) {
const buckets = Array . isArray ( scan ? . top_buckets ) ? scan . top_buckets : [];
if ( ! buckets . length ) return [];
2026-04-21 21:12:50 +08:00
return buckets
. map (( item ) => ({
... item ,
probability : normalizeMarketProbability ( item . probability ),
}))
. filter (
( item ) : item is MarketTopBucket & { probability : number } =>
item . probability != null ,
);
}
function getMarketAllBuckets ( scan? : MarketScan | null ) {
const buckets = Array . isArray ( scan ? . all_buckets )
? scan.all_buckets
: Array.isArray ( scan ? . top_buckets )
? scan . top_buckets
: [];
if ( ! buckets . length ) return [];
2026-03-10 09:02:56 +08:00
return buckets
. map (( item ) => ({
... item ,
probability : normalizeMarketProbability ( item . probability ),
}))
. filter (
( item ) : item is MarketTopBucket & { probability : number } =>
item . probability != null ,
);
2026-03-09 10:36:03 +08:00
}
2026-03-11 11:04:24 +08:00
function getMarketTopBucketKey ( bucket : MarketTopBucket ) {
2026-03-11 11:37:07 +08:00
if ( bucket ? . value != null ) {
const valueNum = Number ( bucket . value );
if ( Number . isFinite ( valueNum )) return `v: ${ valueNum . toFixed ( 2 ) } ` ;
}
2026-03-11 11:04:24 +08:00
2026-03-11 11:37:07 +08:00
if ( bucket ? . temp != null ) {
const tempNum = Number ( bucket . temp );
if ( Number . isFinite ( tempNum )) return `t: ${ tempNum . toFixed ( 2 ) } ` ;
}
2026-03-11 11:04:24 +08:00
const parsed = parseTempFromText ( bucket ? . label );
if ( parsed != null ) return `l: ${ parsed . toFixed ( 2 ) } ` ;
return `s: ${ String ( bucket ? . slug || bucket ? . question || bucket ? . label || "" ) } ` ;
}
2026-04-17 20:53:37 +08:00
function hasLgbmModel ( detail : CityDetail , targetDate? : string | null ) {
const view = getModelView ( detail , targetDate );
return Object . keys ( view . models || {}). some (( name ) =>
normalizeModelNameForVote ( name ). includes ( "lgbm" ),
);
}
function formatProbabilityEngineLabel (
detail : CityDetail ,
targetDate : string | null | undefined ,
locale : string ,
) {
const view = getProbabilityView ( detail , targetDate );
if ( hasLgbmModel ( detail , targetDate )) {
return locale === "en-US"
? "LGBM-calibrated probability"
: "LGBM 校准概率" ;
}
const engine = String ( view . engine || "" ). trim (). toLowerCase ();
2026-04-19 03:41:39 +08:00
const calibrationMode = String ( view . calibrationMode || "" )
. trim ()
. toLowerCase ();
if ( engine === "emos" || calibrationMode . includes ( "emos" )) {
2026-04-17 20:53:37 +08:00
return locale === "en-US" ? "EMOS-calibrated probability" : "EMOS 校准概率" ;
}
return locale === "en-US" ? "Model probability" : "模型概率" ;
}
2026-03-09 10:36:03 +08:00
export function HeroSummary() {
const { data } = useCityData ();
2026-03-10 04:45:40 +08:00
const { locale } = useI18n ();
2026-03-09 10:36:03 +08:00
if ( ! data ) return null ;
2026-03-10 04:45:40 +08:00
const { weatherIcon , weatherText } = getWeatherSummary ( data , locale );
const metaItems = getHeroMetaItems ( data , locale );
2026-03-09 10:36:03 +08:00
const current = data . current || {};
2026-04-17 00:19:47 +08:00
const settlementSourceCode = String ( current . settlement_source || "metar" )
. trim ()
. toLowerCase ();
const settlementIcao = String (
current . station_code || data . risk ? . icao || "" ,
)
. trim ()
. toUpperCase ();
const settlementSource =
settlementSourceCode === "wunderground"
? settlementIcao
? ` ${ settlementIcao } METAR`
: "METAR"
: String ( current . settlement_source_label || current . settlement_source || "METAR" )
. trim ()
. toUpperCase ();
2026-03-09 10:36:03 +08:00
const isMax =
current . max_so_far != null &&
current . temp != null &&
current . max_so_far <= current . temp ;
2026-04-21 19:13:33 +08:00
const currentObsText =
current . temp != null
? ` ${ current . temp }${ data . temp_symbol } @ ${ current . obs_time || "--" } `
: data . metar_status ? . stale_for_today
? locale === "en-US"
? "No same-day METAR"
: "今日暂无 METAR"
: "--" ;
2026-03-09 10:36:03 +08:00
return (
< section className = "hero-section" >
< div className = "hero-weather" >
< span >
{ weatherIcon } { weatherText }
</ span >
</ div >
< div className = "hero-temp" >
< span className = "hero-value" >
{ current . temp != null ? current . temp . toFixed ( 1 ) : "--" }
</ span >
< span className = "hero-unit" >{ data . temp_symbol || "°C" }</ span >
</ div >
< div className = "hero-max-time" >
{ isMax && current . max_temp_time
2026-03-10 04:45:40 +08:00
? locale === "en-US"
? `Today's peak temperature appeared at local time ${ current . max_temp_time } `
: `该城市今日最高温出现在当地时间 ${ current . max_temp_time } `
2026-03-09 10:36:03 +08:00
: "" }
</ div >
< div className = "hero-details" >
< div className = "hero-item" >
2026-03-10 09:02:56 +08:00
< span className = "label" >
{ locale === "en-US" ? "Current Obs" : "当前实测" }
</ span >
2026-03-09 10:36:03 +08:00
< span className = "value" >
2026-04-21 19:13:33 +08:00
{ currentObsText }
2026-03-09 10:36:03 +08:00
</ span >
</ div >
< div className = "hero-item" >
2026-03-10 04:45:40 +08:00
< span className = "label" >
2026-03-17 13:21:14 +08:00
{ locale === "en-US"
2026-04-17 00:19:47 +08:00
? ` ${ settlementSource } Anchor`
: ` ${ settlementSource } 锚点` }
2026-03-10 04:45:40 +08:00
</ span >
2026-03-09 10:36:03 +08:00
< span className = "value highlight" >
{ current . wu_settlement != null
? ` ${ current . wu_settlement }${ data . temp_symbol } `
: "--" }
</ span >
</ div >
< div className = "hero-item" >
2026-03-10 09:02:56 +08:00
< span className = "label" >
{ locale === "en-US" ? "DEB Forecast" : "DEB 预测" }
</ span >
2026-03-09 10:36:03 +08:00
< span className = "value" >
{ data . deb ? . prediction != null
? ` ${ data . deb . prediction }${ data . temp_symbol } `
: "--" }
</ span >
</ div >
</ div >
< div className = "hero-sub" >
{ metaItems . map (( item ) => (
< span key = { item }>{ item }</ span >
))}
</ div >
</ section >
);
}
export function TemperatureChart() {
const { data } = useCityData ();
2026-03-10 04:45:40 +08:00
const { locale , t } = useI18n ();
2026-03-18 20:38:43 +08:00
const chartData = useMemo (
() => ( data ? getTemperatureChartData ( data , locale ) : null ),
[ data , locale ],
);
2026-03-09 10:36:03 +08:00
2026-03-10 09:02:56 +08:00
const canvasRef = useChart (() => {
if ( ! data || ! chartData ) {
return {
data : { datasets : [], labels : [] },
type : "line" ,
} satisfies ChartConfiguration < "line" > ;
}
const datasets : NonNullable <
ChartConfiguration < "line" > [ "data" ]
> [ "datasets" ] = [];
if ( chartData . datasets . hasMgmHourly ) {
datasets . push ({
backgroundColor : "rgba(234, 179, 8, 0.05)" ,
borderColor : "rgba(234, 179, 8, 0.8)" ,
borderWidth : 2 ,
data : chartData.datasets.mgmHourlyPoints ,
fill : false ,
label : locale === "en-US" ? "MGM Forecast" : "MGM 预报" ,
pointHoverRadius : 6 ,
pointRadius : 3 ,
spanGaps : true ,
tension : 0.3 ,
});
} else {
datasets . push ({
backgroundColor : "rgba(52, 211, 153, 0.05)" ,
borderColor : "rgba(52, 211, 153, 0.6)" ,
borderWidth : 1.5 ,
data : chartData.datasets.debPast ,
fill : true ,
label : locale === "en-US" ? "DEB Forecast" : "DEB 预报" ,
pointHoverRadius : 3 ,
pointRadius : 0 ,
tension : 0.3 ,
});
datasets . push ({
borderColor : "rgba(52, 211, 153, 0.35)" ,
borderDash : [ 5 , 3 ],
borderWidth : 1.5 ,
data : chartData.datasets.debFuture ,
fill : false ,
label : locale === "en-US" ? "DEB Forecast" : "DEB 预报" ,
pointRadius : 0 ,
tension : 0.3 ,
});
}
datasets . push ({
backgroundColor : "#22d3ee" ,
borderColor : "#22d3ee" ,
borderWidth : 0 ,
data : chartData.datasets.metarPoints ,
fill : false ,
2026-03-17 23:15:13 +08:00
label :
chartData.observationLabel ||
( locale === "en-US" ? "METAR Observation" : "METAR 实况" ),
2026-03-10 09:02:56 +08:00
order : 0 ,
pointHoverRadius : 7 ,
pointRadius : 5 ,
});
2026-03-09 10:36:03 +08:00
2026-03-10 09:02:56 +08:00
if ( chartData . datasets . mgmPoints . some (( value ) => value != null )) {
2026-03-09 10:36:03 +08:00
datasets . push ({
2026-03-10 09:02:56 +08:00
backgroundColor : "#facc15" ,
borderColor : "#facc15" ,
2026-03-09 10:36:03 +08:00
borderWidth : 0 ,
2026-03-10 09:02:56 +08:00
data : chartData.datasets.mgmPoints ,
2026-03-09 10:36:03 +08:00
fill : false ,
2026-03-10 09:02:56 +08:00
label : locale === "en-US" ? "MGM Observation" : "MGM 实测" ,
order : - 1 ,
pointHoverRadius : 9 ,
pointRadius : 7 ,
showLine : false ,
2026-03-09 10:36:03 +08:00
});
2026-03-10 09:02:56 +08:00
}
2026-03-09 10:36:03 +08:00
2026-03-10 09:02:56 +08:00
if (
! chartData . datasets . hasMgmHourly &&
Math . abs ( chartData . datasets . offset ) > 0.3
) {
datasets . push ({
borderColor : "rgba(99, 102, 241, 0.2)" ,
borderDash : [ 2 , 4 ],
borderWidth : 1 ,
data : chartData.datasets.temps ,
fill : false ,
label : locale === "en-US" ? "OM Raw" : "OM 原始" ,
pointRadius : 0 ,
tension : 0.3 ,
});
}
2026-03-09 10:36:03 +08:00
2026-03-10 09:02:56 +08:00
return {
data : {
datasets ,
labels : chartData.times ,
},
options : {
interaction : { intersect : false , mode : "index" },
maintainAspectRatio : false ,
plugins : {
legend : { display : false },
tooltip : {
backgroundColor : "rgba(15, 23, 42, 0.9)" ,
borderColor : "rgba(52, 211, 153, 0.3)" ,
borderWidth : 1 ,
},
2026-03-09 10:36:03 +08:00
},
2026-03-10 09:02:56 +08:00
responsive : true ,
scales : {
x : {
grid : { color : "rgba(255,255,255,0.04)" },
ticks : {
callback : ( _value , index ) =>
typeof index === "number" && index % 3 === 0
? chartData . times [ index ]
: "" ,
color : "#64748b" ,
maxRotation : 0 ,
2026-03-09 10:36:03 +08:00
},
},
2026-03-10 09:02:56 +08:00
y : {
grid : { color : "rgba(255,255,255,0.04)" },
max : chartData.max ,
min : chartData.min ,
ticks : {
callback : ( value ) => ` ${ value }${ data . temp_symbol || "°C" } ` ,
color : "#64748b" ,
2026-03-09 10:36:03 +08:00
},
},
},
2026-03-10 09:02:56 +08:00
},
type : "line" ,
} satisfies ChartConfiguration < "line" > ;
}, [ data , chartData , locale ]);
2026-03-09 10:36:03 +08:00
return (
< section className = "chart-section" >
2026-03-10 04:45:40 +08:00
< h3 >{ t ( "section.todayTempTrend" )}</ h3 >
2026-03-09 10:36:03 +08:00
< div className = "chart-wrapper" >
< canvas ref = { canvasRef } />
</ div >
2026-03-10 09:02:56 +08:00
< div className = "chart-legend" >
{ chartData ? . legendText || t ( "section.chartEmpty" )}
</ div >
2026-03-09 10:36:03 +08:00
</ section >
);
}
export function ProbabilityDistribution ({
detail ,
hideTitle = false ,
targetDate ,
2026-03-10 09:02:56 +08:00
marketScan ,
2026-03-09 10:36:03 +08:00
} : {
detail : CityDetail ;
hideTitle? : boolean ;
targetDate? : string | null ;
2026-03-10 09:02:56 +08:00
marketScan? : MarketScan | null ;
2026-03-09 10:36:03 +08:00
}) {
2026-03-10 09:02:56 +08:00
const { locale , t } = useI18n ();
2026-03-09 10:36:03 +08:00
const view = getProbabilityView ( detail , targetDate );
2026-03-10 09:02:56 +08:00
const marketYesPrice = getMarketYesPrice ( marketScan );
const marketYesText = toPercent ( marketYesPrice );
const isToday = ! targetDate || targetDate === detail . local_date ;
2026-04-17 20:53:37 +08:00
const probabilityEngineLabel = formatProbabilityEngineLabel (
detail ,
targetDate ,
locale ,
);
const hasLgbmProbability = hasLgbmModel ( detail , targetDate );
2026-04-17 00:48:12 +08:00
const modelVoteView = useMemo (
() => getRoundedModelVoteDistribution ( detail , targetDate ),
[ detail , targetDate ],
);
2026-04-17 01:11:04 +08:00
const modelVoteHint = modelVoteView . rows
. slice ( 0 , 2 )
. map (
( row ) =>
` ${ row . value }${ detail . temp_symbol } ${ row . count } / ${ modelVoteView . total } ` ,
)
. join ( " · " );
2026-03-10 09:02:56 +08:00
const marketTopBuckets = isToday ? getMarketTopBuckets ( marketScan ) : [];
2026-04-21 21:12:50 +08:00
const marketAllBuckets = isToday ? getMarketAllBuckets ( marketScan ) : [];
2026-03-18 20:38:43 +08:00
const sortedMarketTopBuckets = useMemo (() => {
2026-03-11 11:04:24 +08:00
const sorted = [... marketTopBuckets ]. sort (
( a , b ) => Number ( b . probability || 0 ) - Number ( a . probability || 0 ),
);
const deduped : Array < MarketTopBucket & { probability : number }> = [];
const seenKeys = new Set < string >();
for ( const row of sorted ) {
const key = getMarketTopBucketKey ( row );
if ( seenKeys . has ( key )) continue ;
seenKeys . add ( key );
deduped . push ( row );
if ( deduped . length >= 4 ) break ;
}
return deduped ;
2026-03-18 20:38:43 +08:00
}, [ marketTopBuckets ]);
2026-03-10 09:02:56 +08:00
const useMarketTopBuckets =
2026-03-11 11:04:24 +08:00
marketScan ? . available && sortedMarketTopBuckets . length >= 2 ;
2026-03-10 09:02:56 +08:00
const topMarketBucketText = toPercent ( sortedMarketTopBuckets [ 0 ] ? . probability );
2026-04-17 20:53:37 +08:00
const topProbability = [...( view . probabilities || [])]. sort (
( a , b ) => Number ( b . probability || 0 ) - Number ( a . probability || 0 ),
)[ 0 ];
const topProbabilityText = toPercent ( topProbability ? . probability );
const topProbabilityLabel = topProbability
2026-04-22 00:53:58 +08:00
? formatBucketDisplayLabel ( topProbability , detail )
2026-04-17 20:53:37 +08:00
: null ;
2026-04-21 21:12:50 +08:00
const topProbabilityTemp = topProbability ? getBucketTemp ( topProbability ) : null ;
2026-04-22 01:43:13 +08:00
const probabilitiesForMarketContracts =
view . probabilitiesAll ? . length > 0
? view.probabilitiesAll
: view.probabilities || [];
2026-04-22 01:09:22 +08:00
const marketContractRows = useMemo < ProbabilityDisplayRow [] >(() => {
if ( ! isToday || ! marketScan ? . available || marketAllBuckets . length === 0 ) {
return [];
}
const rows : ProbabilityDisplayRow [] = [];
const seenKeys = new Set < string >();
for ( const marketBucket of marketAllBuckets ) {
const probability = getAggregatedModelProbabilityForMarketBucket (
2026-04-22 01:43:13 +08:00
probabilitiesForMarketContracts ,
2026-04-22 01:09:22 +08:00
marketBucket ,
detail ,
);
const key =
marketBucket . slug ||
marketBucket . label ||
` ${ marketBucket . lower ?? marketBucket . value ?? marketBucket . temp } - ${ marketBucket . upper ?? "" } ` ;
if ( seenKeys . has ( key )) continue ;
seenKeys . add ( key );
rows . push ({
key ,
label : formatMarketBucketDisplayLabel ( marketBucket , detail ),
2026-04-22 01:26:27 +08:00
probability : probability ?? 0 ,
2026-04-22 01:09:22 +08:00
marketBucket ,
});
}
return rows ;
2026-04-22 01:43:13 +08:00
}, [
detail ,
isToday ,
marketAllBuckets ,
marketScan ? . available ,
probabilitiesForMarketContracts ,
]);
2026-04-22 01:09:22 +08:00
const modelProbabilityRows = useMemo < ProbabilityDisplayRow [] >(
() =>
( view . probabilities || []). slice ( 0 , 6 ). map (( bucket , index ) => {
const bucketTemp = getBucketTemp ( bucket );
return {
key : ` ${ bucket . label || bucket . value || index } ` ,
label : formatBucketDisplayLabel ( bucket , detail ),
probability : Number ( bucket . probability || 0 ),
marketBucket : findMarketBucketForDisplayTemp (
marketAllBuckets ,
bucketTemp ,
detail ,
),
};
}),
[ detail , marketAllBuckets , view . probabilities ],
);
const probabilityRows =
marketContractRows . length > 0
? marketContractRows . slice ( 0 , 8 )
: modelProbabilityRows ;
const topContractRow =
marketContractRows . length > 0
? marketContractRows . reduce (( best , row ) =>
row . probability > best . probability ? row : best ,
)
: null ;
2026-04-22 02:30:49 +08:00
const displayTopLabel = topContractRow ? . label || topProbabilityLabel || null ;
const displayTopProbability =
topContractRow ? . probability ??
( topProbability ? . probability != null
? Number ( topProbability . probability )
: null );
const displayTopProbabilityText = toPercent ( displayTopProbability );
const displayUsesMarketBuckets = marketContractRows . length > 0 ;
2026-04-21 21:12:50 +08:00
const linkedMarketBucket = useMemo (() => {
2026-04-22 01:09:22 +08:00
if ( topContractRow ? . marketBucket ) return topContractRow . marketBucket ;
2026-04-21 21:12:50 +08:00
if ( topProbabilityTemp == null ) return null ;
2026-04-22 00:09:49 +08:00
return findMarketBucketForDisplayTemp (
marketAllBuckets ,
topProbabilityTemp ,
detail ,
2026-04-21 21:12:50 +08:00
);
2026-04-22 01:09:22 +08:00
}, [ detail , marketAllBuckets , topContractRow , topProbabilityTemp ]);
2026-04-21 21:06:48 +08:00
const priceAnalysis = marketScan ? . price_analysis ;
const yesPriceView = priceAnalysis ? . yes ;
const noPriceView = priceAnalysis ? . no ;
2026-04-21 21:12:50 +08:00
const linkedMarketAsk =
linkedMarketBucket ? . yes_buy ??
linkedMarketBucket ? . market_price ??
yesPriceView ? . ask ??
null ;
2026-04-21 23:18:42 +08:00
const linkedNoAsk = linkedMarketBucket ? . no_buy ?? noPriceView ? . ask ?? null ;
2026-04-22 00:53:58 +08:00
const linkedContractLabel =
2026-04-22 01:09:22 +08:00
topContractRow ? . label ||
( linkedMarketBucket ? formatMarketBucketDisplayLabel ( linkedMarketBucket , detail ) : null ) ||
topProbabilityLabel ||
null ;
2026-04-22 00:53:58 +08:00
const aggregatedMarketProbability = getAggregatedModelProbabilityForMarketBucket (
2026-04-22 01:43:13 +08:00
probabilitiesForMarketContracts ,
2026-04-22 00:53:58 +08:00
linkedMarketBucket ,
detail ,
);
2026-04-21 21:12:50 +08:00
const linkedMarketProbability =
2026-04-22 01:09:22 +08:00
topContractRow ? . probability ??
2026-04-22 00:53:58 +08:00
aggregatedMarketProbability ??
( topProbability ? . probability != null ? Number ( topProbability . probability ) : null );
const linkedMarketProbabilityText = toPercent ( linkedMarketProbability );
2026-04-21 21:12:50 +08:00
const linkedMarketEdge =
linkedMarketProbability != null && linkedMarketAsk != null
? linkedMarketProbability - Number ( linkedMarketAsk )
: null ;
2026-04-21 23:18:42 +08:00
const linkedNoEdge =
linkedMarketProbability != null && linkedNoAsk != null
? 1 - linkedMarketProbability - Number ( linkedNoAsk )
: null ;
const linkedBestSide =
linkedMarketBucket && linkedNoEdge != null && linkedMarketEdge != null
? linkedNoEdge > linkedMarketEdge
? "no"
: "yes"
2026-04-21 21:12:50 +08:00
: null ;
2026-04-21 23:18:42 +08:00
const linkedBestAsk = linkedBestSide === "no" ? linkedNoAsk : linkedMarketAsk ;
const linkedBestEdge =
linkedBestSide === "no" ? linkedNoEdge : linkedMarketEdge ;
2026-04-21 21:06:48 +08:00
const preferredPriceView =
2026-04-21 21:12:50 +08:00
linkedMarketBucket
? {
2026-04-21 23:18:42 +08:00
ask : linkedBestAsk ,
edge : linkedBestEdge ,
2026-04-21 21:12:50 +08:00
}
: priceAnalysis ? . best_side === "no"
? noPriceView
: yesPriceView ;
2026-04-21 21:06:48 +08:00
const preferredSideLabel =
2026-04-21 21:12:50 +08:00
linkedMarketBucket
2026-04-21 23:18:42 +08:00
? linkedBestSide === "no"
? "NO"
: "YES"
2026-04-21 21:12:50 +08:00
: priceAnalysis ? . best_side === "no"
2026-04-21 21:06:48 +08:00
? locale === "en-US"
? "NO"
: "NO"
: locale === "en-US"
? "YES"
: "YES" ;
2026-04-21 23:27:31 +08:00
const yesDisplayPrice = linkedMarketBucket ? linkedMarketAsk : yesPriceView?.ask ;
const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask ;
const yesDisplayEdge = linkedMarketBucket
? linkedMarketEdge
: yesPriceView?.edge ;
const noDisplayEdge = linkedMarketBucket ? linkedNoEdge : noPriceView?.edge ;
2026-04-21 21:06:48 +08:00
const hasPriceAnalysis =
2026-04-21 21:12:50 +08:00
isToday &&
( Boolean ( priceAnalysis ? . available ) ||
Boolean ( marketScan ) ||
Boolean ( topProbability ));
2026-04-21 21:06:48 +08:00
const lockEdge = normalizeSignedProbability ( priceAnalysis ? . lock ? . edge );
const lockAvailable = Boolean ( priceAnalysis ? . lock ? . available && lockEdge != null );
const quoteSource =
2026-04-21 21:12:50 +08:00
linkedMarketBucket ? . quote_source ||
2026-04-21 21:06:48 +08:00
marketScan ? . yes_token ? . quote_source ||
marketScan ? . no_token ? . quote_source ||
null ;
const quoteAgeMs =
2026-04-21 21:12:50 +08:00
linkedMarketBucket ? . quote_age_ms ??
marketScan ? . yes_token ? . quote_age_ms ??
marketScan ? . no_token ? . quote_age_ms ;
2026-04-21 21:06:48 +08:00
const quoteSourceLabel =
quoteSource === "polymarket_ws"
? locale === "en-US"
? `WS live ${ quoteAgeMs != null ? ` · ${ Math . max ( 0 , Math . round ( Number ( quoteAgeMs ) / 1000 )) } s` : "" } `
: `WS 实时 ${ quoteAgeMs != null ? ` · ${ Math . max ( 0 , Math . round ( Number ( quoteAgeMs ) / 1000 )) } 秒` : "" } `
: locale === "en-US"
? "CLOB fallback"
: "CLOB 兜底" ;
2026-04-21 23:18:42 +08:00
const actionableEdge = normalizeSignedProbability ( preferredPriceView ? . edge );
2026-04-22 01:59:23 +08:00
const linkedContractOverpriced =
Boolean ( linkedMarketBucket ) &&
linkedBestSide === "no" &&
linkedMarketProbability != null &&
linkedMarketAsk != null &&
linkedMarketEdge != null &&
linkedMarketEdge < 0 &&
linkedNoEdge != null &&
linkedNoEdge > 0 ;
const linkedContractOverpay =
linkedContractOverpriced && linkedMarketProbability != null && linkedMarketAsk != null
? Number ( linkedMarketAsk ) - linkedMarketProbability
: null ;
2026-04-21 23:18:42 +08:00
const actionText =
! marketScan
? locale === "en-US"
? "Waiting"
: "等待"
: ! marketScan . available
? locale === "en-US"
? "No market"
: "无盘口"
: actionableEdge == null
? locale === "en-US"
? "No quote"
: "无报价"
: actionableEdge >= 0.02
2026-04-22 01:59:23 +08:00
? linkedContractOverpriced
2026-04-21 23:18:42 +08:00
? locale === "en-US"
2026-04-22 01:59:23 +08:00
? "Overpriced"
: "市场偏贵"
: locale === "en-US"
? `Watch ${ preferredSideLabel } `
: `可关注 ${ preferredSideLabel } `
: actionableEdge > 0
? linkedContractOverpriced
? locale === "en-US"
? "Slightly overpriced"
: "略偏贵"
: locale === "en-US"
? `Small ${ preferredSideLabel } `
: ` ${ preferredSideLabel } 优势较小`
2026-04-21 23:18:42 +08:00
: locale === "en-US"
? "No clear edge"
: "暂无优势" ;
const actionNote =
2026-04-22 01:59:23 +08:00
linkedContractOverpriced && linkedContractOverpay != null
? locale === "en-US"
? `YES above model by ${ formatSignedPercent ( linkedContractOverpay ) } `
: `YES 高于模型 ${ formatSignedPercent ( linkedContractOverpay ) } `
: actionableEdge != null && actionableEdge >= 0.02
2026-04-21 23:18:42 +08:00
? locale === "en-US"
2026-04-21 23:27:31 +08:00
? ` ${ formatSignedPercent ( actionableEdge ) } vs ask`
: `相对买价 ${ formatSignedPercent ( actionableEdge ) } `
2026-04-21 23:18:42 +08:00
: locale === "en-US"
2026-04-21 23:27:31 +08:00
? ` ${ preferredSideLabel } ${ formatSignedPercent ( actionableEdge ) } `
: ` ${ preferredSideLabel } ${ formatSignedPercent ( actionableEdge ) } ` ;
2026-03-09 10:36:03 +08:00
return (
< section className = "prob-section" >
2026-03-10 04:45:40 +08:00
{ ! hideTitle && < h3 >{ t ( "section.probability" )}</ h3 >}
2026-03-09 10:36:03 +08:00
< div className = "prob-bars" >
2026-04-17 20:53:37 +08:00
< div className = "prob-calibration-head" >
< div >
< span className = "prob-source-chip" >{ probabilityEngineLabel }</ span >
< strong >
2026-04-22 02:30:49 +08:00
{ displayTopLabel && displayTopProbabilityText
2026-04-17 20:53:37 +08:00
? locale === "en-US"
2026-04-22 02:30:49 +08:00
? displayUsesMarketBuckets
? ` ${ displayTopLabel } is the top displayed contract bucket at ${ displayTopProbabilityText } `
: ` ${ displayTopLabel } is the top single bucket at ${ displayTopProbabilityText } `
: displayUsesMarketBuckets
? ` ${ displayTopLabel } 为当前显示分布最高, ${ displayTopProbabilityText } `
: ` ${ displayTopLabel } 单点最高, ${ displayTopProbabilityText } `
2026-04-17 20:53:37 +08:00
: locale === "en-US"
? "Awaiting calibrated buckets"
: "等待校准概率桶" }
</ strong >
</ div >
< p >
{ hasLgbmProbability
? locale === "en-US"
2026-04-22 01:59:23 +08:00
? "LGBM is the learned intraday adjustment; raw model points below are only diagnostic."
: "LGBM 作为日内学习校准项;下方原始模型落点仅用于诊断。"
2026-04-17 20:53:37 +08:00
: locale === "en-US"
2026-04-22 01:59:23 +08:00
? "Using the calibrated probability distribution; raw model points below are not probabilities."
: "使用校准后的概率分布;下方原始模型落点不是概率。" }
2026-04-17 20:53:37 +08:00
</ p >
</ div >
2026-03-10 09:02:56 +08:00
{ marketScan ? . available && ( topMarketBucketText || marketYesText ) && (
< div
style = {{
color : "var(--text-secondary)" ,
fontSize : "11px" ,
marginBottom : "6px" ,
}}
>
{ useMarketTopBuckets
? locale === "en-US"
2026-04-17 20:53:37 +08:00
? `Market reference only: top traded bucket ${ topMarketBucketText } `
: `市场仅作参考:最高交易温度桶 ${ topMarketBucketText } `
2026-03-10 09:02:56 +08:00
: locale === "en-US"
2026-04-17 20:53:37 +08:00
? `Market reference only: this bucket ${ marketYesText } `
2026-04-22 02:07:03 +08:00
: `市场仅作参考:该温度桶 ${ marketYesText } ` }
2026-03-10 09:02:56 +08:00
</ div >
)}
2026-04-22 02:07:03 +08:00
< div className = "prob-distribution-panel" >
< div className = "prob-distribution-head" >
< span >
{ locale === "en-US"
? "EMOS probability distribution"
: "EMOS 概率分布" }
</ span >
< em >
{ marketContractRows . length > 0
? locale === "en-US"
2026-04-22 02:18:25 +08:00
? "market buckets are aggregated from single-degree EMOS buckets"
: "市场合约桶由单点 EMOS 概率聚合"
2026-04-22 02:07:03 +08:00
: locale === "en-US"
? "calibrated temperature buckets"
: "校准后的温度桶" }
</ em >
</ div >
{ probabilityRows . length === 0 ? (
< EmptyState text = { t ( "section.noProb" )} />
) : (
probabilityRows . map (( row , index ) => {
const probability = Math . round ( Number ( row . probability || 0 ) * 100 );
const rowMarketBucket = row . marketBucket ;
const rowMarketPrice =
2026-04-22 02:59:12 +08:00
rowMarketBucket ? . yes_buy ?? rowMarketBucket ? . market_price ?? null ;
2026-04-22 02:07:03 +08:00
const yesPriceText = toPriceCents ( rowMarketPrice );
const marketTagFinal = rowMarketBucket
? locale === "en-US"
2026-04-22 02:59:12 +08:00
? `YES ask: ${ yesPriceText || "--" } `
: `YES 买价: ${ yesPriceText || "--" } `
2026-04-22 02:07:03 +08:00
: null ;
return (
< div
key = { ` ${ row . key || index } ` }
className = "prob-row"
>
< div className = "prob-label" >{ row . label }</ div >
< div className = "prob-bar-track" >
< div
className = { clsx ( "prob-bar-fill" , `rank- ${ index } ` )}
style = {{ width : ` ${ Math . max ( probability , 8 ) } %` }}
>
{ probability } %
</ div >
</ div >
{ marketTagFinal && (
< div
className = { clsx (
"prob-market-inline" ,
rowMarketBucket ? "yes" : "no" ,
)}
>
{ marketTagFinal }
</ div >
)}
</ div >
);
})
)}
</ div >
2026-04-21 21:06:48 +08:00
{ hasPriceAnalysis && (
< div className = "prob-price-card" >
< div className = "prob-price-head" >
< span >
2026-04-21 21:12:50 +08:00
{ locale === "en-US" ? "Probability x Price" : "概率 x 价格联动" }
2026-04-21 21:06:48 +08:00
</ span >
< strong >
2026-04-21 21:12:50 +08:00
{ ! marketScan
? locale === "en-US"
? "Waiting for market layer"
: "等待市场层"
: ! marketScan . available
? locale === "en-US"
? "No matched active market"
: "未匹配到活跃盘口"
: linkedMarketBucket
? locale === "en-US"
2026-04-22 00:53:58 +08:00
? ` ${ actionText } : ${ linkedContractLabel || "contract bucket" } `
: ` ${ actionText } : ${ linkedContractLabel || "合约桶" } `
2026-04-21 21:12:50 +08:00
: preferredPriceView ? . edge != null
2026-04-21 21:06:48 +08:00
? locale === "en-US"
2026-04-21 23:18:42 +08:00
? ` ${ actionText } · edge ${ formatSignedPercent ( preferredPriceView . edge ) } `
: ` ${ actionText } · 优势 ${ formatSignedPercent ( preferredPriceView . edge ) } `
2026-04-21 21:06:48 +08:00
: locale === "en-US"
? "Waiting for executable quote"
: "等待可执行报价" }
</ strong >
</ div >
< div className = "prob-price-grid" >
< div >
2026-04-22 00:53:58 +08:00
< span >{ locale === "en-US" ? "Contract bucket" : "合约桶口径" }</ span >
< strong >{ linkedContractLabel || topProbabilityLabel || "--" }</ strong >
< em >{ linkedMarketProbabilityText || topProbabilityText || "--" }</ em >
2026-04-21 21:06:48 +08:00
</ div >
< div >
2026-04-21 23:18:42 +08:00
< span >{ locale === "en-US" ? "Candidate" : "可关注" }</ span >
< strong >{ actionText }</ strong >
< em >{ actionNote }</ em >
</ div >
< div >
2026-04-21 23:27:31 +08:00
< span >{ locale === "en-US" ? "YES price" : "YES 价格" }</ span >
< strong >{ toPriceCents ( yesDisplayPrice ) || "--" }</ strong >
2026-04-21 21:12:50 +08:00
< em >
2026-04-21 23:27:31 +08:00
{ locale === "en-US"
? `edge ${ formatSignedPercent ( yesDisplayEdge ) } `
: `优势 ${ formatSignedPercent ( yesDisplayEdge ) } ` }
2026-04-21 21:12:50 +08:00
</ em >
2026-04-21 21:06:48 +08:00
</ div >
< div >
2026-04-21 23:27:31 +08:00
< span >{ locale === "en-US" ? "NO price" : "NO 价格" }</ span >
< strong >{ toPriceCents ( noDisplayPrice ) || "--" }</ strong >
2026-04-21 21:12:50 +08:00
< em >
2026-04-21 23:27:31 +08:00
{ locale === "en-US"
? `edge ${ formatSignedPercent ( noDisplayEdge ) } `
: `优势 ${ formatSignedPercent ( noDisplayEdge ) } ` }
2026-04-21 21:12:50 +08:00
</ em >
2026-04-21 21:06:48 +08:00
</ div >
</ div >
< p >
{ locale === "en-US"
2026-04-21 23:18:42 +08:00
? `Read-only comparison between model probability and executable ask; it does not place orders. Source: ${ quoteSourceLabel }${ lockAvailable ? ` · lock ${ formatSignedPercent ( lockEdge ) } ` : "" } .`
: `只比较模型概率与可执行买价;系统不会下单。来源: ${ quoteSourceLabel }${ lockAvailable ? ` · 锁价 ${ formatSignedPercent ( lockEdge ) } ` : "" } 。` }
2026-04-21 21:06:48 +08:00
</ p >
</ div >
)}
2026-04-17 01:11:04 +08:00
{ modelVoteHint && (
< div className = "prob-model-hint" >
< span >
2026-04-22 01:59:23 +08:00
{ locale === "en-US" ? "Raw model points" : "原始模型落点" }
2026-04-17 01:11:04 +08:00
</ span >
< strong >{ modelVoteHint }</ strong >
< em >
2026-04-17 00:48:12 +08:00
{ locale === "en-US"
2026-04-22 01:59:23 +08:00
? "diagnostic only; EMOS and contract rows use calibrated probabilities"
: "仅作诊断;EMOS 与合约行使用校准概率" }
2026-04-17 01:11:04 +08:00
</ em >
2026-04-17 00:48:12 +08:00
</ div >
)}
2026-03-09 10:36:03 +08:00
</ div >
</ section >
);
}
export function ModelForecast ({
detail ,
hideTitle = false ,
targetDate ,
} : {
detail : CityDetail ;
hideTitle? : boolean ;
targetDate? : string | null ;
}) {
2026-03-10 10:07:18 +08:00
const { locale , t } = useI18n ();
2026-03-09 10:36:03 +08:00
const view = getModelView ( detail , targetDate );
2026-03-10 10:37:14 +08:00
const modelsMap = { ... view . models };
2026-04-17 00:35:48 +08:00
const modelMetadata =
detail . source_forecasts ? . open_meteo_multi_model ? . model_metadata || {};
2026-03-10 10:37:14 +08:00
const modelEntries = Object . entries ( modelsMap ). filter (
([, value ]) =>
value !== null && value !== undefined && Number . isFinite ( Number ( value )),
2026-03-09 10:36:03 +08:00
);
2026-04-08 15:23:34 +08:00
const hasSingleModelOnly = modelEntries . length === 1 ;
2026-03-10 10:37:14 +08:00
// 如果没有任何数值,给出提示
if ( modelEntries . length === 0 ) {
return (
< section className = "models-section" >
{ ! hideTitle && < h3 >{ t ( "section.models" )}</ h3 >}
< div className = "model-bars" >
< EmptyState text = { t ( "section.noModels" )} />
</ div >
</ section >
);
}
2026-03-09 10:36:03 +08:00
const numericValues = modelEntries . map (([, value ]) => Number ( value ));
const comparisonValues =
view . deb != null ? [... numericValues , Number ( view . deb )] : numericValues ;
2026-03-10 09:02:56 +08:00
const minValue = comparisonValues . length
? Math . min (... comparisonValues ) - 1
: 0 ;
const maxValue = comparisonValues . length
? Math . max (... comparisonValues ) + 1
: 1 ;
2026-03-09 10:36:03 +08:00
const range = Math . max ( maxValue - minValue , 1 );
2026-04-17 00:35:48 +08:00
const sortedEntries = modelEntries . sort (
( a , b ) => Number ( b [ 1 ] || 0 ) - Number ( a [ 1 ] || 0 ),
);
const groupedEntries = sortedEntries . reduce (
( acc , [ name , value ]) => {
const group = getModelGroupMeta ( name , modelMetadata , locale );
const existing = acc . find (( item ) => item . key === group . key );
const entry = {
metaLine : formatModelMetaLine ( name , modelMetadata , locale ),
name ,
value : Number ( value ),
};
if ( existing ) {
existing . entries . push ( entry );
} else {
acc . push ({ ... group , entries : [ entry ] });
}
return acc ;
},
[] as Array < {
entries : Array < { metaLine : string ; name : string ; value : number } > ;
key : string ;
label : string ;
order : number ;
tone : string ;
} > ,
). sort (( a , b ) => a . order - b . order );
const spread =
numericValues . length >= 2
? Math . max (... numericValues ) - Math . min (... numericValues )
: null ;
const metadataSource =
detail . source_forecasts ? . open_meteo_multi_model ? . provider === "open-meteo"
? "Open-Meteo"
: null ;
2026-03-09 10:36:03 +08:00
return (
< section className = "models-section" >
2026-03-10 04:45:40 +08:00
{ ! hideTitle && < h3 >{ t ( "section.models" )}</ h3 >}
2026-03-09 10:36:03 +08:00
< div className = "model-bars" >
2026-04-17 00:35:48 +08:00
< div className = "model-stack-summary" >
< span >
{ locale === "en-US" ? "Available models" : "可用模型" } · { " " }
< strong >{ modelEntries . length }</ strong >
</ span >
< span >
{ locale === "en-US" ? "Spread" : "分歧" } · { " " }
< strong >
{ spread != null
? ` ${ spread . toFixed ( 1 ) }${ detail . temp_symbol } `
: "--" }
</ strong >
</ span >
{ metadataSource && (
< span >
2026-04-17 01:11:04 +08:00
{ locale === "en-US" ? "API" : "接口" } · { " " }
2026-04-17 00:35:48 +08:00
< strong >{ metadataSource }</ strong >
</ span >
)}
</ div >
2026-04-08 15:23:34 +08:00
{ hasSingleModelOnly && (
< div
style = {{
color : "var(--text-secondary)" ,
fontSize : "11px" ,
marginBottom : "8px" ,
}}
>
{ locale === "en-US"
? "Single-model fallback: waiting for the rest of the model cluster."
: "当前处于单模型回退,其他模型结果还没回传。" }
</ div >
)}
2026-04-17 00:35:48 +08:00
{ groupedEntries . map (( group ) => (
< div
key = { group . key }
className = { clsx ( "model-group" , `model-group- ${ group . tone } ` )}
>
< div className = "model-group-heading" >
< span >{ group . label }</ span >
< em >{ group . entries . length }</ em >
</ div >
{ group . entries . map (({ metaLine , name , value }) => {
const width = (( value - minValue ) / range ) * 100 ;
const debLine =
view . deb != null
? (( Number ( view . deb ) - minValue ) / range ) * 100
: null ;
return (
< div key = { name } className = "model-row model-row-rich" >
< div className = "model-name" title = { metaLine || name }>
< strong >{ name }</ strong >
{ metaLine && < span >{ metaLine }</ span >}
2026-03-09 10:36:03 +08:00
</ div >
2026-04-17 00:35:48 +08:00
< div className = "model-bar-track" >
2026-03-10 10:37:14 +08:00
< div
2026-04-17 00:35:48 +08:00
className = "model-bar-fill"
style = {{ width : ` ${ width } %` }}
2026-04-17 00:40:46 +08:00
/>
< span className = "model-bar-value" >
2026-04-17 00:35:48 +08:00
{ value }
{ detail . temp_symbol }
2026-04-17 00:40:46 +08:00
</ span >
2026-04-17 00:35:48 +08:00
{ debLine != null && (
< div
className = "model-deb-line"
style = {{ left : ` ${ debLine } %` }}
/>
)}
</ div >
2026-03-09 10:36:03 +08:00
</ div >
2026-04-17 00:35:48 +08:00
);
})}
</ div >
))}
2026-03-10 10:37:14 +08:00
{ view . deb != null && (
< div
className = "model-row"
style = {{
borderTop : "1px solid rgba(255,255,255,0.06)" ,
marginTop : "6px" ,
paddingTop : "6px" ,
}}
>
< div
className = "model-name"
style = {{ color : "var(--accent-cyan)" , fontWeight : 700 }}
>
DEB
</ div >
< div className = "model-bar-track" >
< div
className = "model-bar-fill deb"
style = {{
width : ` ${ (( Number ( view . deb ) - minValue ) / range ) * 100 } %` ,
}}
2026-04-17 00:40:46 +08:00
/>
< span className = "model-bar-value deb" >
2026-03-10 10:37:14 +08:00
{ Number ( view . deb )}
{ detail . temp_symbol }
2026-04-17 00:40:46 +08:00
</ span >
2026-03-10 10:37:14 +08:00
</ div >
</ div >
2026-03-09 10:36:03 +08:00
)}
</ div >
</ section >
);
}
export function ForecastTable() {
const store = useDashboardStore ();
const { data } = useCityData ();
2026-04-18 01:04:36 +08:00
const { locale , t } = useI18n ();
2026-03-09 10:36:03 +08:00
if ( ! data ) return null ;
const daily = data . forecast ? . daily || [];
2026-04-08 16:20:02 +08:00
const isSparseDaily = daily . length <= 1 ;
2026-04-18 01:04:36 +08:00
const isForecastCompleting =
2026-04-18 14:40:29 +08:00
store . loadingState . cityDetail &&
( data . detail_depth !== "full" || isSparseDaily );
2026-03-23 21:41:27 +08:00
const resolveForecastTemp = ( date : string , fallback : number | null | undefined ) => {
const debPrediction = data . multi_model_daily ? .[ date ] ? . deb ? . prediction ;
return debPrediction ?? fallback ?? null ;
};
2026-03-09 10:36:03 +08:00
return (
< section className = "forecast-section" >
2026-03-10 04:45:40 +08:00
< h3 >{ t ( "forecast.title" )}</ h3 >
2026-04-08 16:20:02 +08:00
{ isSparseDaily && (
2026-04-18 01:04:36 +08:00
< div className = "forecast-inline-note" >
{ isForecastCompleting
? locale === "en-US"
? "Multi-day forecast is syncing. Only the current-day card has arrived."
: "多日预报同步中,当前只到达当日卡片。"
: locale === "en-US"
? "Only the current-day forecast is available right now."
: "当前只收到当日预报,其他日期结果暂未回传。" }
2026-04-08 16:20:02 +08:00
</ div >
)}
2026-03-09 10:36:03 +08:00
< div className = "forecast-table" >
{ daily . length === 0 ? (
2026-03-10 04:45:40 +08:00
< EmptyState text = { t ( "forecast.empty" )} />
2026-03-09 10:36:03 +08:00
) : (
daily . map (( day , index ) => {
const isToday = day . date === data . local_date || index === 0 ;
const isSelected =
2026-04-17 20:40:17 +08:00
( isToday &&
store . forecastModalMode === "today" &&
Boolean ( store . futureModalDate )) ||
( store . forecastModalMode !== "today" &&
store . futureModalDate === day . date ) ||
2026-03-09 10:36:03 +08:00
store . selectedForecastDate === day . date ;
return (
< button
key = { day . date }
type = "button"
2026-03-10 09:02:56 +08:00
className = { clsx (
"forecast-day" ,
isToday && "today" ,
isSelected && "selected" ,
)}
2026-03-09 10:36:03 +08:00
onClick = {() => {
2026-03-18 20:38:43 +08:00
startTransition (() => {
2026-04-17 20:40:17 +08:00
if ( isToday ) {
store . openTodayModal ();
return ;
}
2026-03-18 20:38:43 +08:00
store . openFutureModal ( day . date );
});
2026-03-09 10:36:03 +08:00
}}
>
< div className = "f-date" >
2026-03-10 09:02:56 +08:00
{ isToday
? t ( "forecast.today" )
: day . date . substring ( 5 ). replace ( "-" , "/" )}
2026-03-09 10:36:03 +08:00
</ div >
< div className = "f-temp" >
2026-03-23 21:41:27 +08:00
{ resolveForecastTemp ( day . date , day . max_temp )}
2026-03-09 10:36:03 +08:00
{ data . temp_symbol }
</ div >
</ button >
);
2026-04-18 01:04:36 +08:00
}). concat (
isForecastCompleting
? Array . from ({ length : Math.max ( 0 , 5 - daily . length ) }). map (( _ , index ) => (
< button
key = { `forecast-sync- ${ index } ` }
type = "button"
className = "forecast-day forecast-day-sync"
disabled
>
< div className = "f-date" >
{ locale === "en-US" ? "Syncing" : "同步中" }
</ div >
< div className = "f-temp" > -- </ div >
</ button >
))
: [],
)
2026-03-09 10:36:03 +08:00
)}
</ div >
</ section >
);
}
export function RiskInfo() {
const { data } = useCityData ();
2026-03-10 04:45:40 +08:00
const { t } = useI18n ();
2026-03-09 10:36:03 +08:00
if ( ! data ) return null ;
const risk = data . risk || {};
return (
< section className = "risk-section" >
2026-03-10 04:45:40 +08:00
< h3 >{ t ( "section.risk" )}</ h3 >
2026-03-09 10:36:03 +08:00
< div className = "risk-info" >
{ ! risk . airport ? (
2026-03-10 09:02:56 +08:00
< span style = {{ color : "var(--text-muted)" }}>
{ t ( "section.noRiskProfile" )}
</ span >
2026-03-09 10:36:03 +08:00
) : (
<>
< div className = "risk-row" >
2026-03-10 04:45:40 +08:00
< span className = "risk-label" >{ t ( "section.airport" )}</ span >
2026-03-09 10:36:03 +08:00
< span >
{ risk . airport } ({ risk . icao })
</ span >
</ div >
< div className = "risk-row" >
2026-03-10 04:45:40 +08:00
< span className = "risk-label" >{ t ( "section.distance" )}</ span >
2026-03-09 10:36:03 +08:00
< span >{ risk . distance_km } km </ span >
</ div >
{ risk . warning && (
< div className = "risk-row" >
2026-03-10 04:45:40 +08:00
< span className = "risk-label" >{ t ( "section.note" )}</ span >
2026-03-09 10:36:03 +08:00
< span >{ risk . warning }</ span >
</ div >
)}
</>
)}
</ div >
</ section >
);
}