2026-03-09 10:36:03 +08:00
"use client" ;
2026-03-10 09:02:56 +08:00
import {
Cloud ,
CloudFog ,
CloudLightning ,
CloudRain ,
CloudSnow ,
CloudSun ,
Search ,
Sun ,
Wind ,
} from "lucide-react" ;
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-04-13 18:42:27 +08:00
import { CSSProperties , useEffect , useMemo , useState } from "react" ;
2026-03-09 10:36:03 +08:00
import { useChart } from "@/hooks/useChart" ;
import { useDashboardStore } from "@/hooks/useDashboardStore" ;
2026-03-10 04:45:40 +08:00
import { useI18n } from "@/hooks/useI18n" ;
2026-03-13 06:41:33 +08:00
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall" ;
2026-03-09 10:36:03 +08:00
import {
ModelForecast ,
ProbabilityDistribution ,
} from "@/components/dashboard/PanelSections" ;
import {
getFutureModalView ,
2026-04-08 11:16:51 +08:00
getModelView ,
getProbabilityView ,
2026-04-08 10:33:58 +08:00
getTodayPaceView ,
2026-03-09 10:36:03 +08:00
getTemperatureChartData ,
getWeatherSummary ,
} from "@/lib/dashboard-utils" ;
2026-04-21 23:01:38 +08:00
import { dashboardClient } from "@/lib/dashboard-client" ;
import type { IntradayMeteorologySignal , MarketScan } from "@/lib/dashboard-types" ;
2026-03-09 10:36:03 +08:00
2026-03-10 09:02:56 +08:00
function normalizeMarketValue ( 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-13 16:35:22 +08:00
function formatMinuteAxisLabel ( value : number ) {
if ( ! Number . isFinite ( value )) return "" ;
const total = Math . max ( 0 , Math . round ( value ));
const hour = Math . floor ( total / 60 ) % 24 ;
const minute = total % 60 ;
return ` ${ String ( hour ). padStart ( 2 , "0" ) } : ${ String ( minute ). padStart ( 2 , "0" ) } ` ;
}
2026-03-10 09:02:56 +08:00
function WeatherIcon ({ emoji , size = 32 } : { emoji : string ; size? : number }) {
if ( emoji === "☀️" ) return < Sun size = { size } color = "#facc15" />;
if ( emoji === "⛅" || emoji === "🌤️" )
return < CloudSun size = { size } color = "#38bdf8" />;
if ( emoji === "☁️" ) return < Cloud size = { size } color = "#94a3b8" />;
if ( emoji === "🌧️" || emoji === "🌦️" )
return < CloudRain size = { size } color = "#60a5fa" />;
if ( emoji === "⛈️" ) return < CloudLightning size = { size } color = "#c084fc" />;
if ( emoji === "❄️" || emoji === "🌨️" )
return < CloudSnow size = { size } color = "#7dd3fc" />;
if ( emoji === "🌫️" ) return < CloudFog size = { size } color = "#a1a1aa" />;
if ( emoji === "💨" ) return < Wind size = { size } color = "#cbd5e1" />;
return < Search size = { size } color = "#64748b" />;
}
function formatMarketPercent ( value? : number | null ) {
const normalized = normalizeMarketValue ( value );
if ( normalized == null ) return "--" ;
return ` ${ ( normalized * 100 ). toFixed ( 1 ) } %` ;
}
function formatBucketLabel (
bucket ?: {
label? : string | null ;
bucket? : string | null ;
range? : string | null ;
value? : number | null ;
temp? : number | null ;
} | null ,
) {
if ( ! bucket ) return "--" ;
const direct =
String ( bucket . label || "" ). trim () ||
String ( bucket . bucket || "" ). trim () ||
String ( bucket . range || "" ). trim ();
if ( direct ) {
let str = direct . toUpperCase (). replace ( /\s+/g , "" );
str = str . replace ( /°?C($|\+|-)/g , "℃$1" );
if ( ! str . includes ( "℃" ) && /[0-9]/ . test ( str )) {
str += "℃" ;
}
return str ;
}
const temp = Number ( bucket . value ?? bucket . temp );
if ( Number . isFinite ( temp )) {
return ` ${ Math . round ( temp ) } ℃` ;
}
return "--" ;
}
2026-04-08 11:16:51 +08:00
function parseBucketBoundaries (
bucket ?: {
label? : string | null ;
bucket? : string | null ;
range? : string | null ;
value? : number | null ;
temp? : number | null ;
} | null ,
) {
if ( ! bucket ) return null ;
const raw =
String ( bucket . label || "" ). trim () ||
String ( bucket . bucket || "" ). trim () ||
String ( bucket . range || "" ). trim ();
if ( ! raw ) return null ;
const numbers = Array . from ( raw . matchAll ( /-?\d+(?:\.\d+)?/g )). map (( match ) =>
Number ( match [ 0 ]),
);
if ( ! numbers . length ) return null ;
if ( raw . includes ( "+" )) {
return {
lower : numbers [ 0 ] ?? null ,
upper : null as number | null ,
boundaryLabel : ` ${ numbers [ 0 ] } °C` ,
};
}
if ( numbers . length >= 2 ) {
return {
lower : numbers [ 0 ],
upper : numbers [ 1 ],
boundaryLabel : null as string | null ,
};
}
return {
lower : numbers [ 0 ],
upper : null as number | null ,
boundaryLabel : ` ${ numbers [ 0 ] } °C` ,
};
}
2026-03-27 23:34:48 +08:00
function clamp ( value : number , min : number , max : number ) {
return Math . min ( Math . max ( value , min ), max );
}
function parseClockMinutes ( value? : string | null ) {
const text = String ( value || "" ). trim ();
const match = text . match ( /^(\d{1,2}):(\d{2})$/ );
if ( ! match ) return null ;
const hours = Number ( match [ 1 ]);
const minutes = Number ( match [ 2 ]);
if ( ! Number . isFinite ( hours ) || ! Number . isFinite ( minutes )) return null ;
return hours * 60 + minutes ;
}
function parseLeadingNumber ( value? : string | number | null ) {
if ( typeof value === "number" && Number . isFinite ( value )) return value ;
const text = String ( value || "" ). trim ();
const match = text . match ( /[-+]?\d+(?:\.\d+)?/ );
if ( ! match ) return null ;
const numeric = Number ( match [ 0 ]);
return Number . isFinite ( numeric ) ? numeric : null ;
}
2026-04-11 11:00:01 +08:00
function parsePercentFromText ( value? : string | number | null ) {
if ( typeof value === "number" && Number . isFinite ( value )) {
return clamp ( value , 0 , 100 );
}
const text = String ( value || "" ). trim ();
const percentMatch = text . match ( /([-+]?\d+(?:\.\d+)?)\s*%/ );
if ( percentMatch ) {
const numeric = Number ( percentMatch [ 1 ]);
return Number . isFinite ( numeric ) ? clamp ( numeric , 0 , 100 ) : null ;
}
return parseLeadingNumber ( text );
}
2026-04-16 17:13:53 +08:00
function formatConfidenceLabel ( value? : string | null , locale = "zh-CN" ) {
const normalized = String ( value || "" ). trim (). toLowerCase ();
if ( normalized === "high" ) return locale === "en-US" ? "High" : "高" ;
if ( normalized === "medium" ) return locale === "en-US" ? "Medium" : "中" ;
if ( normalized === "low" ) return locale === "en-US" ? "Low" : "低" ;
return locale === "en-US" ? "Pending" : "待确认" ;
}
function formatSignalDirection ( value? : string | null , locale = "zh-CN" ) {
const normalized = String ( value || "" ). trim (). toLowerCase ();
if ( normalized === "support" ) return locale === "en-US" ? "Support" : "支持升温" ;
if ( normalized === "suppress" ) return locale === "en-US" ? "Suppress" : "压制峰值" ;
return locale === "en-US" ? "Neutral" : "中性" ;
}
function formatSignalStrength ( value? : string | null , locale = "zh-CN" ) {
const normalized = String ( value || "" ). trim (). toLowerCase ();
if ( normalized === "strong" ) return locale === "en-US" ? "Strong" : "强" ;
if ( normalized === "medium" ) return locale === "en-US" ? "Medium" : "中" ;
return locale === "en-US" ? "Weak" : "弱" ;
}
function signalTone ( signal? : IntradayMeteorologySignal | null ) {
const direction = String ( signal ? . direction || "" ). trim (). toLowerCase ();
if ( direction === "support" ) return "cyan" ;
if ( direction === "suppress" ) return "amber" ;
return "blue" ;
}
2026-04-16 17:34:39 +08:00
function localizedText (
locale : string ,
primary? : string | null ,
english? : string | null ,
) {
const en = String ( english || "" ). trim ();
const value = String ( primary || "" ). trim ();
if ( locale === "en-US" && en ) return en ;
return value || en ;
}
function localizedList (
locale : string ,
primary? : string [] | null ,
english? : string [] | null ,
) {
const en = Array . isArray ( english )
? english . filter (( item ) => String ( item || "" ). trim ())
: [];
const value = Array . isArray ( primary )
? primary . filter (( item ) => String ( item || "" ). trim ())
: [];
if ( locale === "en-US" && en . length ) return en ;
return value . length ? value : en ;
}
2026-03-27 23:34:48 +08:00
function getTrendMetricVisual ( metric : {
label? : string ;
value? : string ;
tone? : string ;
}) {
const label = String ( metric . label || "" ). toLowerCase ();
const value = String ( metric . value || "" );
const numeric = parseLeadingNumber ( value );
if ( label . includes ( "降水" ) || label . includes ( "precip" )) {
2026-04-11 11:00:01 +08:00
const precipPercent = parsePercentFromText ( value );
if ( precipPercent == null ) return null ;
2026-03-27 23:34:48 +08:00
return {
mode : "fill" as const ,
2026-04-11 11:00:01 +08:00
percent : precipPercent ,
2026-03-27 23:34:48 +08:00
tone : "cold" as const ,
};
}
2026-04-11 11:00:01 +08:00
if ( numeric == null ) return null ;
2026-03-27 23:34:48 +08:00
if ( label . includes ( "温度" ) || label . includes ( "temp" )) {
return {
mode : "center" as const ,
percent : clamp ( 50 + ( numeric / 4 ) * 50 , 0 , 100 ),
tone : numeric >= 0 ? "warm" as const : "cold" as const ,
};
}
if ( label . includes ( "露点" ) || label . includes ( "dew" )) {
return {
mode : "center" as const ,
percent : clamp ( 50 + ( numeric / 3 ) * 50 , 0 , 100 ),
tone : numeric >= 0 ? "warm" as const : "cold" as const ,
};
}
if ( label . includes ( "气压" ) || label . includes ( "pressure" )) {
return {
mode : "center" as const ,
percent : clamp ( 50 + ( numeric / 4 ) * 50 , 0 , 100 ),
tone : numeric >= 0 ? "warm" as const : "cold" as const ,
};
}
if ( label . includes ( "云量" ) || label . includes ( "cloud" )) {
return {
mode : "center" as const ,
percent : clamp ( 50 + ( numeric / 40 ) * 50 , 0 , 100 ),
tone : numeric >= 0 ? "cold" as const : "warm" as const ,
};
}
return null ;
}
2026-04-17 20:40:17 +08:00
function DailyTemperatureChart ({
dateStr ,
forceToday = false ,
} : {
dateStr : string ;
forceToday? : boolean ;
}) {
2026-03-09 10:36:03 +08:00
const store = useDashboardStore ();
2026-03-10 04:45:40 +08:00
const { locale , t } = useI18n ();
2026-03-09 10:36:03 +08:00
const detail = store . selectedDetail ;
2026-03-10 04:45:40 +08:00
const view = detail ? getFutureModalView ( detail , dateStr , locale ) : null ;
2026-04-17 20:40:17 +08:00
const isToday = forceToday || ( detail ? dateStr === detail.local_date : false );
2026-03-18 20:38:43 +08:00
const todayChartData = useMemo (
() => ( detail && isToday ? getTemperatureChartData ( detail , locale ) : null ),
[ detail , isToday , locale ],
);
2026-03-09 10:36:03 +08:00
2026-03-10 09:02:56 +08:00
const canvasRef = useChart (() => {
if ( ! detail || ! view ) {
return {
data : { datasets : [], labels : [] },
type : "line" ,
} satisfies ChartConfiguration < "line" > ;
}
2026-03-09 10:36:03 +08:00
2026-03-10 09:02:56 +08:00
if ( isToday && todayChartData ) {
const datasets : NonNullable <
ChartConfiguration < "line" > [ "data" ]
> [ "datasets" ] = [];
2026-03-09 10:36:03 +08:00
2026-03-10 09:02:56 +08:00
if ( todayChartData . datasets . hasMgmHourly ) {
2026-03-09 10:36:03 +08:00
datasets . push ({
2026-03-10 09:02:56 +08:00
backgroundColor : "rgba(234, 179, 8, 0.05)" ,
borderColor : "rgba(234, 179, 8, 0.8)" ,
borderWidth : 2 ,
2026-04-13 16:35:22 +08:00
data : todayChartData.datasets.mgmHourlySeries ,
2026-03-09 10:36:03 +08:00
fill : false ,
2026-03-10 09:02:56 +08:00
label : locale === "en-US" ? "MGM Forecast" : "MGM 预测" ,
2026-04-13 16:35:22 +08:00
parsing : false ,
2026-03-10 09:02:56 +08:00
pointHoverRadius : 6 ,
pointRadius : 3 ,
spanGaps : true ,
tension : 0.3 ,
2026-03-09 10:36:03 +08:00
});
2026-03-10 09:02:56 +08:00
} else {
datasets . push ({
backgroundColor : "rgba(52, 211, 153, 0.05)" ,
borderColor : "rgba(52, 211, 153, 0.6)" ,
borderWidth : 1.5 ,
2026-04-13 16:35:22 +08:00
data : todayChartData.datasets.debPastSeries ,
2026-03-10 09:02:56 +08:00
fill : true ,
label : locale === "en-US" ? "DEB Forecast" : "DEB 预测" ,
2026-04-13 16:35:22 +08:00
parsing : false ,
2026-03-10 09:02:56 +08:00
pointHoverRadius : 3 ,
pointRadius : 0 ,
tension : 0.3 ,
});
datasets . push ({
borderColor : "rgba(52, 211, 153, 0.35)" ,
borderDash : [ 5 , 3 ],
borderWidth : 1.5 ,
2026-04-13 16:35:22 +08:00
data : todayChartData.datasets.debFutureSeries ,
2026-03-10 09:02:56 +08:00
fill : false ,
label : locale === "en-US" ? "DEB Forecast" : "DEB 预测" ,
2026-04-13 16:35:22 +08:00
parsing : false ,
2026-03-10 09:02:56 +08:00
pointRadius : 0 ,
tension : 0.3 ,
});
}
2026-03-09 10:36:03 +08:00
2026-03-10 09:02:56 +08:00
datasets . push ({
backgroundColor : "#22d3ee" ,
borderColor : "#22d3ee" ,
borderWidth : 0 ,
2026-04-13 16:35:22 +08:00
data : todayChartData.datasets.metarSeries ,
2026-03-10 09:02:56 +08:00
fill : false ,
2026-03-17 23:15:13 +08:00
label :
todayChartData.observationLabel ||
2026-03-17 23:30:25 +08:00
( locale === "en-US" ? "Observation" : "观测实况" ),
2026-03-10 09:02:56 +08:00
order : 0 ,
2026-04-13 16:35:22 +08:00
parsing : false ,
2026-03-10 09:02:56 +08:00
pointHoverRadius : 7 ,
pointRadius : 5 ,
2026-04-13 16:35:22 +08:00
showLine : false ,
2026-03-10 09:02:56 +08:00
});
2026-03-09 10:36:03 +08:00
2026-04-13 16:35:22 +08:00
if ( todayChartData . datasets . airportMetarSeries ? . length > 0 ) {
2026-03-25 18:03:56 +08:00
datasets . push ({
backgroundColor : "#60a5fa" ,
borderColor : "#60a5fa" ,
borderWidth : 1 ,
2026-04-13 16:35:22 +08:00
data : todayChartData.datasets.airportMetarSeries ,
2026-03-25 18:03:56 +08:00
fill : false ,
label :
locale === "en-US" ? "Airport METAR" : "机场 METAR" ,
order : 0 ,
2026-04-13 16:35:22 +08:00
parsing : false ,
2026-03-25 18:03:56 +08:00
pointHoverRadius : 6 ,
pointRadius : 4 ,
showLine : false ,
});
}
2026-04-13 16:35:22 +08:00
if ( todayChartData . datasets . mgmSeries ? . length > 0 ) {
2026-03-10 09:02:56 +08:00
datasets . push ({
backgroundColor : "#facc15" ,
borderColor : "#facc15" ,
borderWidth : 0 ,
2026-04-13 16:35:22 +08:00
data : todayChartData.datasets.mgmSeries ,
2026-03-10 09:02:56 +08:00
fill : false ,
label : locale === "en-US" ? "MGM Observation" : "MGM 实测" ,
order : - 1 ,
2026-04-13 16:35:22 +08:00
parsing : false ,
2026-03-10 09:02:56 +08:00
pointHoverRadius : 9 ,
pointRadius : 7 ,
showLine : false ,
});
2026-03-09 10:36:03 +08:00
}
2026-03-10 09:02:56 +08:00
if (
! todayChartData . datasets . hasMgmHourly &&
Math . abs ( todayChartData . datasets . offset ) > 0.3
) {
datasets . push ({
borderColor : "rgba(99, 102, 241, 0.2)" ,
borderDash : [ 2 , 4 ],
borderWidth : 1 ,
2026-04-13 16:35:22 +08:00
data : todayChartData.datasets.tempsSeries ,
2026-03-10 09:02:56 +08:00
fill : false ,
label : locale === "en-US" ? "OM Raw" : "OM 原始" ,
2026-04-13 16:35:22 +08:00
parsing : false ,
2026-03-10 09:02:56 +08:00
pointRadius : 0 ,
tension : 0.3 ,
});
}
2026-03-24 03:13:54 +08:00
if (( todayChartData . tafMarkers || []). length > 0 ) {
2026-03-25 21:46:33 +08:00
datasets . push ({
backgroundColor : "#f59e0b" ,
borderColor : "#f59e0b" ,
borderWidth : 0 ,
2026-04-13 16:35:22 +08:00
data : todayChartData.datasets.tafCurrentMarkerSeries ,
2026-03-25 21:46:33 +08:00
fill : false ,
label : locale === "en-US" ? "Current TAF" : "当前 TAF" ,
order : - 3 ,
2026-04-13 16:35:22 +08:00
parsing : false ,
2026-03-25 21:46:33 +08:00
pointHoverRadius : 8 ,
pointRadius : 6 ,
showLine : false ,
});
datasets . push ({
backgroundColor : "rgba(250, 204, 21, 0.72)" ,
borderColor : "rgba(250, 204, 21, 0.72)" ,
borderWidth : 0 ,
2026-04-13 16:35:22 +08:00
data : todayChartData.datasets.tafPeakWindowMarkerSeries ,
2026-03-25 21:46:33 +08:00
fill : false ,
label : locale === "en-US" ? "Peak-window TAF" : "峰值窗口 TAF" ,
order : - 2 ,
2026-04-13 16:35:22 +08:00
parsing : false ,
2026-03-25 21:46:33 +08:00
pointHoverRadius : 7 ,
pointRadius : 4 ,
showLine : false ,
});
2026-03-24 03:13:54 +08:00
datasets . push ({
backgroundColor : "#f59e0b" ,
borderColor : "#f59e0b" ,
borderWidth : 0 ,
2026-04-13 16:35:22 +08:00
data : todayChartData.datasets.tafMarkerSeries ,
2026-03-24 03:13:54 +08:00
fill : false ,
label : locale === "en-US" ? "TAF Timing" : "TAF 时段" ,
2026-03-25 21:46:33 +08:00
order : - 4 ,
2026-04-13 16:35:22 +08:00
parsing : false ,
2026-03-25 21:46:33 +08:00
pointHoverRadius : 0 ,
pointRadius : 0 ,
2026-03-24 03:13:54 +08:00
showLine : false ,
});
}
2026-03-09 10:36:03 +08:00
return {
data : {
2026-03-10 09:02:56 +08:00
datasets ,
2026-04-13 16:35:22 +08:00
labels : [],
2026-03-09 10:36:03 +08:00
},
options : {
2026-04-13 16:35:22 +08:00
interaction : { intersect : false , mode : "nearest" },
2026-03-09 10:36:03 +08:00
maintainAspectRatio : false ,
plugins : {
legend : {
labels : {
color : "#94a3b8" ,
2026-03-10 09:02:56 +08:00
filter : ( legendItem , chartData ) => {
const text = String ( legendItem . text || "" );
if ( ! text ) return false ;
2026-03-25 21:46:33 +08:00
if ( text === "TAF Timing" || text === "TAF 时段" ) return false ;
2026-03-10 09:02:56 +08:00
if ( ! text . includes ( "DEB" )) return true ;
const firstDebIndex = ( chartData . datasets || []). findIndex (
( dataset ) => String ( dataset . label || "" ). includes ( "DEB" ),
);
return legendItem . datasetIndex === firstDebIndex ;
},
2026-03-09 10:36:03 +08:00
font : { family : "Inter" , size : 11 },
},
},
tooltip : {
backgroundColor : "rgba(15, 23, 42, 0.96)" ,
borderColor : "rgba(34, 211, 238, 0.2)" ,
borderWidth : 1 ,
2026-03-24 03:13:54 +08:00
callbacks : {
2026-04-13 16:35:22 +08:00
title : ( items ) => {
const rawX = items ? .[ 0 ] ? . parsed ? . x ;
return rawX != null ? formatMinuteAxisLabel ( Number ( rawX )) : "" ;
},
2026-03-24 03:13:54 +08:00
label : ( ctx ) => {
const label = String ( ctx . dataset . label || "" );
2026-04-13 16:35:22 +08:00
const raw = ctx . raw as
| { marker ?: { summary? : string ; markerType? : string ; displayType? : string ; isCurrent? : boolean ; isPeakWindow? : boolean } }
| undefined ;
2026-03-25 21:46:33 +08:00
if (
label === "TAF Timing" ||
label === "TAF 时段" ||
label === "Current TAF" ||
label === "当前 TAF" ||
label === "Peak-window TAF" ||
label === "峰值窗口 TAF"
) {
2026-04-13 16:35:22 +08:00
const marker = raw ? . marker ;
2026-03-24 03:13:54 +08:00
if ( ! marker ) return label ;
2026-04-13 16:35:22 +08:00
const markerType = String ( marker . markerType || "" );
const displayType = String (
marker . displayType || marker . markerType || "" ,
);
const summary = String ( marker . summary || "" );
2026-03-25 21:46:33 +08:00
const prefix =
marker . isCurrent && marker . isPeakWindow
? locale === "en-US"
? "Current / peak-window TAF"
: "当前 / 峰值窗口 TAF"
: marker . isCurrent
? locale === "en-US"
? "Current TAF"
: "当前 TAF"
: marker . isPeakWindow
? locale === "en-US"
? "Peak-window TAF"
: "峰值窗口 TAF"
: label ;
2026-04-13 16:35:22 +08:00
return ` ${ prefix } : ${
markerType ? summary . replace ( markerType , displayType ) : summary
} ` ;
2026-03-24 03:13:54 +08:00
}
const value = ctx . parsed . y ;
if ( value == null ) return label ;
return ` ${ label } : ${ value . toFixed ( 1 ) }${ detail . temp_symbol || "°C" } ` ;
},
},
2026-03-09 10:36:03 +08:00
},
},
responsive : true ,
scales : {
x : {
2026-04-13 16:35:22 +08:00
max : todayChartData.xMax ,
min : todayChartData.xMin ,
2026-03-09 10:36:03 +08:00
grid : { color : "rgba(255,255,255,0.04)" },
2026-04-13 16:35:22 +08:00
type : "linear" ,
2026-03-09 10:36:03 +08:00
ticks : {
2026-04-13 16:35:22 +08:00
callback : ( value ) => {
const num = Number ( value );
if ( ! Number . isFinite ( num )) return "" ;
const minutes = Math . round ( num );
if (
minutes !== todayChartData . xMin &&
minutes !== todayChartData . xMax &&
minutes % 120 !== 0
) {
return "" ;
}
return formatMinuteAxisLabel ( minutes );
},
2026-03-09 10:36:03 +08:00
color : "#64748b" ,
font : { family : "Inter" , size : 10 },
maxRotation : 0 ,
},
},
y : {
grid : { color : "rgba(255,255,255,0.04)" },
2026-03-10 09:02:56 +08:00
max : todayChartData.max ,
min : todayChartData.min ,
2026-03-09 10:36:03 +08:00
ticks : {
2026-03-10 09:02:56 +08:00
callback : ( value ) => ` ${ value }${ detail . temp_symbol || "°C" } ` ,
2026-03-09 10:36:03 +08:00
color : "#64748b" ,
font : { family : "Inter" , size : 10 },
},
},
},
},
type : "line" ,
} satisfies ChartConfiguration < "line" > ;
2026-03-10 09:02:56 +08:00
}
const labels = view . slice . map (( point ) => point . label );
const unit = detail . temp_symbol || "°C" ;
return {
data : {
datasets : [
{
backgroundColor : "rgba(34, 211, 238, 0.08)" ,
borderColor : "#22d3ee" ,
data : view.slice.map (( point ) => point . temp ),
fill : false ,
label :
locale === "en-US" ? "Open-Meteo Temperature" : "Open-Meteo 温度" ,
pointRadius : 2 ,
tension : 0.28 ,
},
{
backgroundColor : "transparent" ,
borderColor : "#a78bfa" ,
borderDash : [ 5 , 4 ],
data : view.slice.map (( point ) => point . dewPoint ),
fill : false ,
label : locale === "en-US" ? "Dew Point" : "露点" ,
pointRadius : 0 ,
tension : 0.24 ,
},
],
labels ,
},
options : {
interaction : { intersect : false , mode : "index" },
maintainAspectRatio : false ,
plugins : {
legend : {
labels : {
color : "#94a3b8" ,
font : { family : "Inter" , size : 11 },
},
},
tooltip : {
backgroundColor : "rgba(15, 23, 42, 0.96)" ,
borderColor : "rgba(34, 211, 238, 0.2)" ,
borderWidth : 1 ,
callbacks : {
label : ( ctx ) =>
` ${ ctx . dataset . label } : ${ ctx . parsed . y ? . toFixed ( 1 ) }${ unit } ` ,
},
},
},
responsive : true ,
scales : {
x : {
grid : { color : "rgba(255,255,255,0.04)" },
ticks : {
color : "#64748b" ,
font : { family : "Inter" , size : 10 },
maxRotation : 0 ,
},
},
y : {
grid : { color : "rgba(255,255,255,0.04)" },
ticks : {
callback : ( value ) => ` ${ value }${ unit } ` ,
color : "#64748b" ,
font : { family : "Inter" , size : 10 },
},
},
},
},
type : "line" ,
} satisfies ChartConfiguration < "line" > ;
}, [ detail , isToday , locale , todayChartData , view ]);
2026-03-09 10:36:03 +08:00
return (
<>
< div className = "history-chart-wrapper future-chart-wrapper" >
< canvas ref = { canvasRef } />
</ div >
{ isToday && (
< div className = "chart-legend" >
2026-03-10 04:45:40 +08:00
{ todayChartData ? . legendText || t ( "future.chartLegendEmpty" )}
2026-03-09 10:36:03 +08:00
</ div >
)}
</>
);
}
export function FutureForecastModal() {
const store = useDashboardStore ();
2026-03-10 04:45:40 +08:00
const { locale , t } = useI18n ();
2026-03-09 10:36:03 +08:00
const detail = store . selectedDetail ;
const dateStr = store . futureModalDate ;
2026-03-13 06:41:33 +08:00
const isPro = store . proAccess . subscriptionActive ;
const isProLoading = store . proAccess . loading ;
2026-04-13 18:42:27 +08:00
const [ showDeferredTodaySections , setShowDeferredTodaySections ] = useState ( false );
2026-04-21 23:01:38 +08:00
const [ freshMarketScan , setFreshMarketScan ] = useState < MarketScan | null >( null );
2026-03-09 10:36:03 +08:00
if ( ! detail || ! dateStr ) return null ;
2026-04-13 18:42:27 +08:00
useEffect (() => {
setShowDeferredTodaySections ( false );
if ( typeof window === "undefined" ) {
setShowDeferredTodaySections ( true );
return ;
}
let cancelled = false ;
let timeoutId : ReturnType < typeof setTimeout > | null = null ;
let idleId : number | null = null ;
const reveal = () => {
if ( ! cancelled ) {
setShowDeferredTodaySections ( true );
}
};
if ( "requestIdleCallback" in window ) {
idleId = window . requestIdleCallback ( reveal , { timeout : 600 });
} else {
timeoutId = setTimeout ( reveal , 120 );
}
return () => {
cancelled = true ;
if ( idleId != null && "cancelIdleCallback" in window ) {
window . cancelIdleCallback ( idleId );
}
if ( timeoutId != null ) {
clearTimeout ( timeoutId );
}
};
}, [ dateStr , detail ]);
2026-04-17 20:40:17 +08:00
const isToday =
store . forecastModalMode === "today" ||
( store . forecastModalMode == null && dateStr === detail . local_date );
2026-04-13 11:19:24 +08:00
const detailDepth = detail . detail_depth || "full" ;
const isFullDetailReady = detailDepth === "full" ;
2026-04-13 18:42:27 +08:00
const isStructureSyncing = store . loadingState . futureDeep || ! isFullDetailReady ;
2026-04-15 16:30:13 +08:00
const isAnyLayerSyncing = isStructureSyncing ;
2026-04-17 21:08:26 +08:00
const isTodayBlockingRefresh = isToday && isStructureSyncing ;
2026-04-21 23:01:38 +08:00
const activeMarketScan = freshMarketScan || detail . market_scan || null ;
useEffect (() => {
setFreshMarketScan ( null );
if ( ! isToday || ! isFullDetailReady || ! isPro ) return ;
const cityName = String ( detail . name || detail . display_name || "" ). trim ();
if ( ! cityName || ! dateStr ) return ;
let cancelled = false ;
2026-04-22 02:30:49 +08:00
let intervalId : ReturnType < typeof setInterval > | null = null ;
const refreshMarketScan = () => {
dashboardClient
. getCityMarketScan ( cityName , {
force : false ,
marketSlug : detail.market_scan?.primary_market?.slug || null ,
targetDate : dateStr ,
})
. then (( payload ) => {
if ( cancelled ) return ;
setFreshMarketScan ( payload . market_scan || null );
})
. catch (() => {
if ( ! cancelled ) {
setFreshMarketScan ( null );
}
});
};
refreshMarketScan ();
intervalId = setInterval (() => {
if ( typeof document !== "undefined" && document . visibilityState === "hidden" ) {
return ;
}
refreshMarketScan ();
}, 3000 );
2026-04-21 23:01:38 +08:00
return () => {
cancelled = true ;
2026-04-22 02:30:49 +08:00
if ( intervalId != null ) {
clearInterval ( intervalId );
}
2026-04-21 23:01:38 +08:00
};
}, [
dateStr ,
detail . display_name ,
detail . local_date ,
detail . market_scan ? . primary_market ? . slug ,
detail . name ,
detail . updated_at ,
isFullDetailReady ,
isPro ,
isToday ,
]);
2026-03-10 04:45:40 +08:00
const view = getFutureModalView ( detail , dateStr , locale );
2026-03-09 10:36:03 +08:00
const scorePosition = ` ${ 50 + view . front . score / 2 } %` ;
const barStyle = {
"--score-position" : scorePosition ,
} as CSSProperties & { "--score-position" : string };
2026-03-10 04:45:40 +08:00
const weatherSummary = getWeatherSummary ( detail , locale );
2026-04-08 10:33:58 +08:00
const paceView = useMemo (
2026-04-13 18:42:27 +08:00
() =>
isToday && showDeferredTodaySections
? getTodayPaceView ( detail , locale )
: null ,
[ detail , isToday , locale , showDeferredTodaySections ],
2026-04-08 10:33:58 +08:00
);
2026-04-08 11:16:51 +08:00
const probabilityView = useMemo (
() => getProbabilityView ( detail , dateStr ),
[ dateStr , detail ],
);
const modelView = useMemo (() => getModelView ( detail , dateStr ), [ dateStr , detail ]);
2026-04-19 03:41:39 +08:00
const probabilityEngineKey = String ( probabilityView ? . engine || "" )
. trim ()
. toLowerCase ();
const probabilityCalibrationMode = String (
probabilityView ? . calibrationMode || "" ,
)
. trim ()
. toLowerCase ();
2026-04-17 20:53:37 +08:00
const hasLgbmProbability = useMemo (
() =>
Object . keys ( modelView ? . models || {}). some (( name ) =>
String ( name || "" ). toLowerCase (). replace ( /[\s_/-]/g , "" ). includes ( "lgbm" ),
),
[ modelView ],
);
2026-04-19 03:41:39 +08:00
const hasEmosProbability =
probabilityEngineKey === "emos" || probabilityCalibrationMode . includes ( "emos" );
const probabilityEngineLabel = hasLgbmProbability
? locale === "en-US"
? "LGBM"
: "LGBM"
: hasEmosProbability
? "EMOS"
: locale === "en-US"
? "Calibrated model"
: "校准模型" ;
const probabilityTitle = hasLgbmProbability
? locale === "en-US"
? "LGBM-Calibrated Probability"
: "LGBM 校准概率"
: hasEmosProbability
? locale === "en-US"
? "EMOS-Calibrated Probability"
: "EMOS 校准概率"
: locale === "en-US"
? "Calibrated Model Probability"
: "校准模型概率" ;
2026-04-08 11:16:51 +08:00
const topProbabilityBucket = useMemo (() => {
const buckets = Array . isArray ( probabilityView ? . probabilities )
? probabilityView . probabilities
: [];
return [... buckets ]
. filter (( bucket ) => Number . isFinite ( Number ( bucket ? . probability )))
. sort (( a , b ) => Number ( b ? . probability ) - Number ( a ? . probability ))[ 0 ];
}, [ probabilityView ]);
const modelSpreadView = useMemo (() => {
const values = Object . values ( modelView ? . models || {})
. map (( value ) => Number ( value ))
. filter (( value ) => Number . isFinite ( value ));
if ( ! values . length ) return null ;
const min = Math . min (... values );
const max = Math . max (... values );
const spread = max - min ;
return {
2026-04-08 15:23:34 +08:00
count : values.length ,
2026-04-08 11:16:51 +08:00
max ,
min ,
spread ,
};
}, [ modelView ]);
const boundaryRiskView = useMemo (() => {
2026-04-13 18:42:27 +08:00
if ( ! showDeferredTodaySections ) return null ;
2026-04-08 11:16:51 +08:00
if ( ! isToday || ! paceView ) return null ;
2026-04-15 16:30:13 +08:00
const selectedBucket = topProbabilityBucket || null ;
2026-04-08 11:16:51 +08:00
const bounds = parseBucketBoundaries ( selectedBucket );
if ( ! bounds ) return null ;
const projected =
paceView . paceAdjustedHigh ??
( detail . deb ? . prediction != null ? Number ( detail . deb . prediction ) : null );
if ( projected == null || ! Number . isFinite ( projected )) return null ;
const distances = [ bounds . lower , bounds . upper ]
. filter (( value ) : value is number => value != null && Number . isFinite ( value ))
. map (( value ) => ({
boundary : value ,
gap : Math.abs ( projected - value ),
}))
. sort (( a , b ) => a . gap - b . gap );
if ( ! distances . length ) return null ;
const nearest = distances [ 0 ];
const tone =
nearest . gap <= 0.4 ? "amber" : nearest . gap <= 0.8 ? "blue" : "cyan" ;
const status =
nearest . gap <= 0.4
? locale === "en-US"
? "High boundary risk"
: "边界风险高"
: nearest . gap <= 0.8
? locale === "en-US"
? "Watch boundary"
: "边界需观察"
: locale === "en-US"
? "Boundary buffer"
: "边界缓冲" ;
const note =
locale === "en-US"
? ` ${ projected . toFixed ( 1 ) }${ detail . temp_symbol } is ${ nearest . gap . toFixed ( 1 ) }${ detail . temp_symbol } from the nearest boundary ${ nearest . boundary . toFixed ( 1 ) } °C.`
: ` ${ projected . toFixed ( 1 ) }${ detail . temp_symbol } 距最近边界 ${ nearest . boundary . toFixed ( 1 ) } °C 还有 ${ nearest . gap . toFixed ( 1 ) }${ detail . temp_symbol } 。` ;
return {
label : locale === "en-US" ? "Boundary risk" : "边界风险" ,
note ,
status ,
tone ,
value : ` ${ nearest . gap . toFixed ( 1 ) }${ detail . temp_symbol } ` ,
};
2026-04-15 16:30:13 +08:00
}, [ detail . deb ? . prediction , detail . temp_symbol , isToday , locale , paceView , showDeferredTodaySections , topProbabilityBucket ]);
2026-04-08 11:16:51 +08:00
const peakWindowStateView = useMemo (() => {
2026-04-13 18:42:27 +08:00
if ( ! showDeferredTodaySections ) return null ;
2026-04-08 11:16:51 +08:00
if ( ! isToday || ! paceView ) return null ;
const firstHour = Number ( detail . peak ? . first_h );
const lastHour = Number ( detail . peak ? . last_h );
if (
! Number . isFinite ( firstHour ) ||
! Number . isFinite ( lastHour ) ||
firstHour < 0 ||
lastHour < firstHour
) {
return null ;
}
const currentMinutes = parseClockMinutes ( detail . local_time );
const startMinutes = firstHour * 60 ;
const endMinutes = ( lastHour + 1 ) * 60 ;
let status = locale === "en-US" ? "Awaiting peak" : "未进入峰值" ;
let tone : "amber" | "blue" | "cyan" = "blue" ;
if ( currentMinutes != null && currentMinutes >= endMinutes ) {
status = locale === "en-US" ? "Past peak" : "已过峰值" ;
tone = "cyan" ;
} else if ( currentMinutes != null && currentMinutes >= startMinutes ) {
status = locale === "en-US" ? "Peak window live" : "峰值窗口进行中" ;
tone = "amber" ;
}
const note =
locale === "en-US"
? `Primary peak window ${ paceView . peakWindowText } .`
: `核心峰值窗口 ${ paceView . peakWindowText } 。` ;
return {
label : locale === "en-US" ? "Peak window" : "峰值窗口状态" ,
note ,
status ,
tone ,
value : paceView.peakWindowText ,
};
2026-04-13 18:42:27 +08:00
}, [ detail . local_time , detail . peak ? . first_h , detail . peak ? . last_h , isToday , locale , paceView , showDeferredTodaySections ]);
2026-04-08 11:16:51 +08:00
const networkLeadView = useMemo (() => {
2026-04-13 18:42:27 +08:00
if ( ! showDeferredTodaySections ) return null ;
2026-04-08 11:16:51 +08:00
if ( ! isToday ) return null ;
const delta = Number ( detail . airport_vs_network_delta );
const leadSignal = detail . network_lead_signal ;
if ( ! Number . isFinite ( delta )) return null ;
const leaderLabel =
String ( leadSignal ? . leader_station_label || "" ). trim () ||
String ( leadSignal ? . leader_station_code || "" ). trim ();
2026-04-19 02:38:34 +08:00
const leaderSyncStatus = String ( leadSignal ? . leader_sync_status || "" ). trim (). toLowerCase ();
const leaderSyncDelta = Number ( leadSignal ? . leader_time_delta_vs_anchor_minutes );
const syncNote =
leaderSyncStatus === "near_realtime" || leaderSyncStatus === "lagged"
? Number . isFinite ( leaderSyncDelta )
? locale === "en-US"
? ` Timing offset versus the airport anchor is about ${ Math . round ( leaderSyncDelta ) } minutes.`
: ` 与机场锚点存在约 ${ Math . round ( leaderSyncDelta ) } 分钟时间差。`
: locale === "en-US"
? " Nearby observations are not fully synchronized."
: " 周边观测并非完全同步。"
: leaderSyncStatus === "unknown"
? locale === "en-US"
? " Nearby station timing is not fully verified."
: " 周边站观测时间尚未完全校验。"
: "" ;
2026-04-08 11:16:51 +08:00
const absDelta = Math . abs ( delta );
const status =
delta <= - 0.4
? locale === "en-US"
? "Airport trailing"
: "机场落后"
: delta >= 0.4
? locale === "en-US"
? "Airport leading"
: "机场领先"
: locale === "en-US"
? "Tracking network"
: "与站网齐平" ;
const tone =
delta <= - 0.4 ? "amber" : delta >= 0.4 ? "cyan" : "blue" ;
const note =
delta <= - 0.4
? locale === "en-US"
2026-04-19 02:38:34 +08:00
? `Airport anchor is ${ absDelta . toFixed ( 1 ) }${ detail . temp_symbol } cooler than the nearby official network ${ leaderLabel ? `, led by ${ leaderLabel } ` : "" } . ${ syncNote } `
: `机场主站当前比周边官方站网低 ${ absDelta . toFixed ( 1 ) }${ detail . temp_symbol }${ leaderLabel ? `,领先点位是 ${ leaderLabel } ` : "" } 。 ${ syncNote } `
2026-04-08 11:16:51 +08:00
: delta >= 0.4
? locale === "en-US"
2026-04-19 02:38:34 +08:00
? `Airport anchor is ${ absDelta . toFixed ( 1 ) }${ detail . temp_symbol } hotter than the nearby official network. ${ syncNote } `
: `机场主站当前比周边官方站网高 ${ absDelta . toFixed ( 1 ) }${ detail . temp_symbol } 。 ${ syncNote } `
2026-04-08 11:16:51 +08:00
: locale === "en-US"
? "Airport anchor and nearby official network are broadly aligned."
: "机场主站与周边官方站网当前大体齐平。" ;
return {
label : locale === "en-US" ? "Airport vs network" : "机场 vs 周边站" ,
note ,
status ,
tone ,
value : ` ${ delta > 0 ? "+" : "" }${ delta . toFixed ( 1 ) }${ detail . temp_symbol } ` ,
};
2026-04-13 18:42:27 +08:00
}, [ detail . airport_vs_network_delta , detail . network_lead_signal , detail . temp_symbol , isToday , locale , showDeferredTodaySections ]);
2026-03-27 20:58:38 +08:00
const isNoaaSettlement =
detail . current ? . settlement_source === "noaa" ||
detail . current ? . settlement_source_label === "NOAA" ;
const noaaStationCode = String (
detail . current ? . station_code || detail . risk ? . icao || "NOAA" ,
)
. trim ()
. toUpperCase ();
const noaaStationName =
String ( detail . current ? . station_name || "" ). trim () ||
String ( detail . risk ? . airport || "" ). trim () ||
noaaStationCode ;
2026-04-15 16:30:13 +08:00
const hottestBucketLabel = formatBucketLabel ( topProbabilityBucket );
2026-04-08 11:16:51 +08:00
const probabilitySummary = (() => {
if ( ! topProbabilityBucket ) {
return locale === "en-US"
? "Probability mass is still too dispersed; avoid over-reading a single bracket."
: "当前概率还比较分散,不要只盯单一区间。" ;
}
const bucketLabel = formatBucketLabel ( topProbabilityBucket );
const bucketProb = formatMarketPercent ( topProbabilityBucket . probability );
2026-04-19 04:02:46 +08:00
if ( ! isToday ) {
return locale === "en-US"
? `Target-day model probability reference puts the leading bucket at ${ bucketLabel } ( ${ bucketProb } ). EMOS is reserved for intraday analysis after live anchor observations arrive.`
: `目标日模型概率参考显示领先温度桶为 ${ bucketLabel } ( ${ bucketProb } )。EMOS 仅用于有实时锚点观测后的日内分析。` ;
}
2026-04-17 20:53:37 +08:00
if ( hasLgbmProbability ) {
return locale === "en-US"
? `LGBM-calibrated read puts the leading bucket at ${ bucketLabel } ( ${ bucketProb } ). Treat this as the base case, not the final settlement.`
: `LGBM 校准后领先温度桶为 ${ bucketLabel } ( ${ bucketProb } )。可作为基准情形,但不要直接等同于最终结算。` ;
}
2026-04-19 03:41:39 +08:00
if ( hasEmosProbability ) {
return locale === "en-US"
? `EMOS-calibrated probability puts the leading bucket at ${ bucketLabel } ( ${ bucketProb } ). It is the primary calibrated probability layer, not the final settlement.`
: `EMOS 校准概率显示领先温度桶为 ${ bucketLabel } ( ${ bucketProb } )。这是当前主概率层,但不要直接等同于最终结算。` ;
}
2026-04-08 11:16:51 +08:00
return locale === "en-US"
2026-04-17 20:53:37 +08:00
? `Calibrated model probability puts the leading bucket at ${ bucketLabel } ( ${ bucketProb } ). Treat this as the base case, not the final settlement.`
: `校准模型概率显示领先温度桶为 ${ bucketLabel } ( ${ bucketProb } )。可作为基准情形,但不要直接等同于最终结算。` ;
2026-04-08 11:16:51 +08:00
})();
const modelSummary = (() => {
if ( ! modelSpreadView ) {
return locale === "en-US"
? "Model spread is unavailable right now."
: "当前拿不到可用的模型分歧。" ;
}
2026-04-08 15:23:34 +08:00
const modelEntries = Object . entries ( modelView ? . models || {}). filter (
([, value ]) => value !== null && value !== undefined && Number . isFinite ( Number ( value )),
);
if ( modelEntries . length === 1 ) {
const [ singleModelName , singleModelValue ] = modelEntries [ 0 ];
return locale === "en-US"
? `Only ${ singleModelName } is available right now at ${ Number ( singleModelValue ). toFixed ( 1 ) }${ detail . temp_symbol } ; multi-model spread is temporarily unavailable.`
: `当前只收到 ${ singleModelName } ${ Number ( singleModelValue ). toFixed ( 1 ) }${ detail . temp_symbol } ,其他多模型暂未回传,所以这里先不判断模型分歧。` ;
}
2026-04-08 11:16:51 +08:00
return locale === "en-US"
? `Model range runs from ${ modelSpreadView . min . toFixed ( 1 ) }${ detail . temp_symbol } to ${ modelSpreadView . max . toFixed ( 1 ) }${ detail . temp_symbol } ; spread ${ modelSpreadView . spread . toFixed ( 1 ) }${ detail . temp_symbol } .`
: `当前模型区间在 ${ modelSpreadView . min . toFixed ( 1 ) }${ detail . temp_symbol } 到 ${ modelSpreadView . max . toFixed ( 1 ) }${ detail . temp_symbol } ,分歧 ${ modelSpreadView . spread . toFixed ( 1 ) }${ detail . temp_symbol } 。` ;
})();
2026-03-24 02:07:15 +08:00
const upperAirSignal = detail . vertical_profile_signal || {};
2026-03-24 03:13:54 +08:00
const tafSignal = detail . taf ? . signal || {};
2026-04-15 16:30:13 +08:00
const upperAirCue = useMemo (() => {
2026-04-13 18:42:27 +08:00
if ( ! showDeferredTodaySections ) return null ;
2026-03-24 03:13:54 +08:00
if ( ! isToday || ( ! upperAirSignal . source && ! tafSignal . available )) return null ;
2026-03-24 02:07:15 +08:00
const setup = String ( upperAirSignal . heating_setup || "neutral" ). toLowerCase ();
2026-03-24 03:13:54 +08:00
const tafSuppression = String (
tafSignal . suppression_level || "low" ,
). toLowerCase ();
const tafDisruption = String (
tafSignal . disruption_level || "low" ,
). toLowerCase ();
const reasons : string [] = [];
let score = 0 ;
2026-03-24 02:07:15 +08:00
if ( setup === "supportive" ) {
2026-03-24 03:13:54 +08:00
score += 2 ;
reasons . push (
locale === "en-US"
? "upper-air structure still supports daytime heating"
: "高空结构仍偏支持白天冲高" ,
);
} else if ( setup === "suppressed" ) {
score -= 2 ;
reasons . push (
locale === "en-US"
? "upper-air structure still leans toward capping the peak"
: "高空结构更偏向压住峰值" ,
);
}
if ( tafSuppression === "high" ) {
score -= 2 ;
reasons . push (
locale === "en-US"
? "TAF flags meaningful cloud/rain suppression near the peak window"
: "TAF 在峰值窗口提示云雨压温风险偏高" ,
);
} else if ( tafSuppression === "medium" ) {
score -= 1 ;
reasons . push (
locale === "en-US"
? "TAF keeps some cloud/rain suppression risk on the table"
: "TAF 仍提示一定的云雨压温风险" ,
);
}
if ( tafDisruption === "high" ) {
score -= 1 ;
reasons . push (
locale === "en-US"
? "TAF also suggests a noisier afternoon regime"
: "TAF 还提示午后扰动偏强" ,
);
} else if ( tafDisruption === "medium" ) {
score -= 0.5 ;
reasons . push (
locale === "en-US"
? "TAF keeps some afternoon timing noise in play"
: "TAF 提示午后仍可能有时段性扰动" ,
);
}
if ( score >= 1.5 ) {
2026-03-24 02:07:15 +08:00
return {
2026-03-24 03:13:54 +08:00
summary :
locale === "en-US"
2026-04-15 16:30:13 +08:00
? "The combined upper-air and TAF read still leans warmer. Do not fade lower buckets too early."
: "高空和 TAF 两层信号合并后仍偏暖侧,不宜过早做更低温区间。" ,
2026-03-24 03:13:54 +08:00
note :
locale === "en-US"
? ` ${ reasons . slice ( 0 , 2 ). join ( "; " ) } .`
: ` ${ reasons . slice ( 0 , 2 ). join ( "; " ) } 。` ,
2026-03-24 02:07:15 +08:00
tone : "warm" ,
value : locale === "en-US" ? "Lean warmer" : "偏暖侧" ,
};
}
2026-03-24 03:13:54 +08:00
if ( score <= - 1.5 ) {
2026-03-24 02:07:15 +08:00
return {
2026-03-24 03:13:54 +08:00
summary :
locale === "en-US"
2026-04-15 16:30:13 +08:00
? "The combined upper-air and TAF read leans more defensive. Be more careful chasing higher buckets."
: "高空和 TAF 两层信号合并后更偏防守,追更高温区间要更谨慎。" ,
2026-03-24 03:13:54 +08:00
note :
locale === "en-US"
? ` ${ reasons . slice ( 0 , 2 ). join ( "; " ) } .`
: ` ${ reasons . slice ( 0 , 2 ). join ( "; " ) } 。` ,
2026-03-24 02:07:15 +08:00
tone : "cold" ,
value : locale === "en-US" ? "Lean cautious" : "偏谨慎" ,
};
}
return {
2026-03-24 03:13:54 +08:00
summary :
locale === "en-US"
2026-04-15 16:30:13 +08:00
? "The combined upper-air and TAF read is mixed. Let surface structure decide before taking a side."
: "高空和 TAF 两层信号目前偏混合,先看近地面结构变化,不急着站边。" ,
2026-03-24 03:13:54 +08:00
note :
locale === "en-US"
? ` ${ reasons . slice ( 0 , 2 ). join ( "; " ) || "No clean edge from the upper-air layer alone" } .`
: ` ${ reasons . slice ( 0 , 2 ). join ( "; " ) || "单看高空层还没有干净的交易边" } 。` ,
2026-03-24 02:07:15 +08:00
tone : "" ,
value : locale === "en-US" ? "Wait / confirm" : "先观察" ,
};
}, [
2026-03-24 03:13:54 +08:00
tafSignal . available ,
tafSignal . disruption_level ,
tafSignal . suppression_level ,
2026-03-24 02:07:15 +08:00
isToday ,
locale ,
upperAirSignal . heating_setup ,
upperAirSignal . source ,
2026-04-13 18:42:27 +08:00
showDeferredTodaySections ,
2026-03-24 02:07:15 +08:00
]);
2026-03-10 09:02:56 +08:00
const topObservedTemp =
detail . current ? . max_so_far != null
? detail.current.max_so_far
: detail.current?.temp ;
const currentTempText =
detail . current ? . temp != null
? ` ${ detail . current . temp }${ detail . temp_symbol } `
: "--" ;
2026-03-27 23:34:48 +08:00
const daylightProgress = (() => {
const now = parseClockMinutes ( detail . current ? . obs_time );
const sunrise = parseClockMinutes ( detail . forecast ? . sunrise );
const sunset = parseClockMinutes ( detail . forecast ? . sunset );
if ( now == null || sunrise == null || sunset == null || sunset <= sunrise ) {
return null ;
}
const percent = clamp ((( now - sunrise ) / ( sunset - sunrise )) * 100 , 0 , 100 );
const phase =
now < sunrise ? "夜间" : now > sunset ? "已日落" : "白昼进行中" ;
return {
phase ,
percent ,
};
})();
2026-04-13 18:42:27 +08:00
const displayedUpperAirSummary = showDeferredTodaySections
2026-04-15 16:30:13 +08:00
? upperAirCue ? . summary || view . front . upperAirSummary
2026-04-13 18:42:27 +08:00
: "" ;
const displayedUpperAirMetrics = showDeferredTodaySections
? ( view . front . upperAirMetrics || []). map (( metric , index ) =>
index === 0 &&
( metric . label === "Trade cue" || metric . label === "交易动作" ) &&
2026-04-15 16:30:13 +08:00
upperAirCue
2026-04-13 18:42:27 +08:00
? {
... metric ,
2026-04-15 16:30:13 +08:00
note : upperAirCue.note ,
tone : upperAirCue.tone ,
value : upperAirCue.value ,
2026-04-13 18:42:27 +08:00
}
: metric ,
)
: [];
2026-04-08 11:41:34 +08:00
const localizedAiCommentaryLines = useMemo (() => {
2026-04-13 18:42:27 +08:00
if ( ! showDeferredTodaySections ) return [] as string [];
2026-04-08 11:41:34 +08:00
const commentary = detail . dynamic_commentary || {};
const headline = String (
locale === "en-US" ? commentary . headline_en || "" : commentary . headline_zh || "" ,
). trim ();
const bullets = (
locale === "en-US" ? commentary.bullets_en : commentary.bullets_zh
) as string [] | null | undefined ;
const cleanedBullets = Array . isArray ( bullets )
? bullets . map (( item ) => String ( item || "" ). trim ()). filter ( Boolean )
: [];
return [ headline , ... cleanedBullets ]. filter ( Boolean ). slice ( 0 , 3 );
2026-04-13 18:42:27 +08:00
}, [ detail . dynamic_commentary , locale , showDeferredTodaySections ]);
2026-04-08 11:16:51 +08:00
const todayTradeSummaryLines = useMemo (() => {
2026-04-13 18:42:27 +08:00
if ( ! showDeferredTodaySections ) return [] as string [];
2026-04-08 11:16:51 +08:00
if ( ! isToday ) return [] as string [];
2026-04-08 11:41:34 +08:00
if ( localizedAiCommentaryLines . length > 0 ) {
return localizedAiCommentaryLines ;
}
2026-04-08 11:16:51 +08:00
const lines : string [] = [];
if ( paceView ) {
const headline =
paceView . biasTone === "warm"
? locale === "en-US"
2026-04-08 11:41:34 +08:00
? `Pace is running hot by ${ paceView . deltaText } ; the day high still leans above the base curve.`
: `节奏偏热 ${ paceView . deltaText } ,日高仍偏向落在基础曲线之上。`
2026-04-08 11:16:51 +08:00
: paceView . biasTone === "cold"
? locale === "en-US"
2026-04-08 11:41:34 +08:00
? `Pace is trailing by ${ paceView . deltaText } ; chasing higher buckets needs caution.`
: `节奏落后 ${ paceView . deltaText } ,继续追更高温区间要更谨慎。`
2026-04-08 11:16:51 +08:00
: locale === "en-US"
2026-04-08 11:41:34 +08:00
? "Pace is still on curve; the next move depends on the peak-window push."
: "节奏目前贴着曲线走,下一步主要看峰值窗口还有没有上冲。" ;
2026-04-08 11:16:51 +08:00
lines . push ( headline );
}
if ( boundaryRiskView ) {
lines . push (
locale === "en-US"
2026-04-08 11:41:34 +08:00
? ` ${ boundaryRiskView . label } : ${ boundaryRiskView . note } `
: ` ${ boundaryRiskView . label } : ${ boundaryRiskView . note } ` ,
2026-04-08 11:16:51 +08:00
);
}
if ( networkLeadView ) {
lines . push (
locale === "en-US"
2026-04-08 11:41:34 +08:00
? ` ${ networkLeadView . label } : ${ networkLeadView . note } `
: ` ${ networkLeadView . label } : ${ networkLeadView . note } ` ,
2026-04-08 11:16:51 +08:00
);
}
2026-04-08 11:41:34 +08:00
return lines . slice ( 0 , 3 );
2026-04-13 18:42:27 +08:00
}, [ boundaryRiskView , isToday , locale , localizedAiCommentaryLines , networkLeadView , paceView , showDeferredTodaySections ]);
2026-04-16 17:13:53 +08:00
const intradayMeteorology = detail . intraday_meteorology || {};
const meteorologySignals = Array . isArray ( intradayMeteorology . signal_contributions )
? intradayMeteorology . signal_contributions
: [];
2026-04-16 17:34:39 +08:00
const invalidationRules = localizedList (
locale ,
intradayMeteorology . invalidation_rules ,
intradayMeteorology . invalidation_rules_en ,
);
const confirmationRules = localizedList (
locale ,
intradayMeteorology . confirmation_rules ,
intradayMeteorology . confirmation_rules_en ,
);
2026-04-16 17:13:53 +08:00
const meteorologyHeadline =
2026-04-16 17:34:39 +08:00
localizedText (
locale ,
intradayMeteorology . headline ,
intradayMeteorology . headline_en ,
) ||
2026-04-16 17:13:53 +08:00
todayTradeSummaryLines [ 0 ] ||
( locale === "en-US"
? "Intraday meteorology layers are still syncing; use the next observation as the anchor."
: "关键日内气象层仍在同步,先以下一次观测作为判断锚点。" );
const baseCaseBucket =
String ( intradayMeteorology . base_case_bucket || "" ). trim () ||
formatBucketLabel ( topProbabilityBucket );
const nextObservationTime =
String ( intradayMeteorology . next_observation_time || "" ). trim () || "--" ;
const baseBucketNumber = parseLeadingNumber ( baseCaseBucket );
const referenceObservedTemp =
topObservedTemp != null && Number . isFinite ( Number ( topObservedTemp ))
? Number ( topObservedTemp )
: detail . current ? . temp != null
? Number ( detail . current . temp )
: null ;
const gapToBaseBucket =
baseBucketNumber != null && referenceObservedTemp != null
? Math . max ( 0 , baseBucketNumber - referenceObservedTemp )
: null ;
const pathStatus =
gapToBaseBucket == null
? locale === "en-US"
? "Awaiting anchor"
: "等待锚点"
: gapToBaseBucket <= 0.05
? locale === "en-US"
? "Base path touched"
: "基准路径已触达"
: gapToBaseBucket <= 1.0
? locale === "en-US"
? "Base path open"
: "基准路径开放"
: locale === "en-US"
? "Needs peak push"
: "需要峰值推动" ;
const peakWindowText =
String ( intradayMeteorology . peak_window || "" ). trim () ||
paceView ? . peakWindowText ||
"--" ;
2026-04-17 00:19:47 +08:00
const settlementSourceCode = String (
detail . current ? . settlement_source || "" ,
). trim (). toLowerCase ();
const settlementStationCode = String (
detail . current ? . station_code || detail . risk ? . icao || "" ,
)
. trim ()
. toUpperCase ();
const settlementStationName =
String ( detail . current ? . station_name || detail . risk ? . airport || "" ). trim () ||
settlementStationCode ||
( locale === "en-US" ? "Anchor station" : "锚点站" );
const airportMetarAnchor =
settlementSourceCode === "metar" ||
settlementSourceCode === "wunderground" ||
Boolean ( settlementStationCode && /^[A-Z]{4}$/ . test ( settlementStationCode ));
const anchorSourceLabel = airportMetarAnchor
? settlementStationCode
? ` ${ settlementStationCode } METAR`
: "METAR"
: detail . current ? . settlement_source_label ||
detail . current ? . settlement_source ||
( locale === "en-US" ? "Official observation" : "官方观测" );
const anchorRuleText = airportMetarAnchor
? locale === "en-US"
? `Airport contract anchor: use the ${ anchorSourceLabel } reports. Wunderground is only a history display page when present.`
: `机场合约锚点:以 ${ anchorSourceLabel } 报文为准;若出现 Wunderground,它只是历史展示页面。`
: locale === "en-US"
? `Official anchor: use ${ anchorSourceLabel } observations for this contract.`
: `官方锚点:该合约按 ${ anchorSourceLabel } 观测口径判断。` ;
const nextObservationLabel = airportMetarAnchor
? locale === "en-US"
? "Next METAR watch"
: "下一次 METAR 观察"
: locale === "en-US"
? "Next anchor watch"
: "下一次锚点观察" ;
const gapToBaseText =
gapToBaseBucket == null
? "--"
: ` ${ gapToBaseBucket . toFixed ( 1 ) }${ detail . temp_symbol || "°C" } ` ;
2026-04-13 11:19:24 +08:00
const syncStatusItems = [
{
key : "base" ,
2026-04-17 21:08:26 +08:00
state : isAnyLayerSyncing ? "syncing" : "ready" ,
2026-04-13 11:19:24 +08:00
label :
2026-04-17 21:08:26 +08:00
isAnyLayerSyncing
? locale === "en-US"
? "Refreshing base analysis"
: "正在刷新基础分析"
: locale === "en-US" ? "Base analysis ready" : "基础分析已加载" ,
2026-04-13 11:19:24 +08:00
note :
2026-04-17 21:08:26 +08:00
isAnyLayerSyncing
? locale === "en-US"
? "Latest anchor readings and forecast curve are being rebuilt."
: "正在重建最新锚点读数和预测曲线。"
: locale === "en-US"
? "Forecast curve, anchor state, and the core intraday view are available."
: "预测曲线、锚点状态和核心日内视图已经可用。" ,
2026-04-13 11:19:24 +08:00
},
{
key : "market" ,
2026-04-17 21:08:26 +08:00
state : isAnyLayerSyncing ? "syncing" : "ready" ,
2026-04-13 11:19:24 +08:00
label :
2026-04-17 21:08:26 +08:00
isAnyLayerSyncing
? locale === "en-US"
? "Refreshing probability layer"
: "正在刷新概率层"
: locale === "en-US"
? "Probability layer ready"
: "概率层已加载" ,
2026-04-13 11:19:24 +08:00
note :
2026-04-17 21:08:26 +08:00
isAnyLayerSyncing
? locale === "en-US"
? "Model spread and calibrated buckets are updating."
: "模型分歧和校准概率桶正在更新。"
: locale === "en-US"
2026-04-19 03:41:39 +08:00
? `Probability buckets are derived from the ${ probabilityEngineLabel } layer.`
: `概率桶当前由 ${ probabilityEngineLabel } 层推导。` ,
2026-04-13 11:19:24 +08:00
},
] as const ;
2026-03-09 10:36:03 +08:00
return (
< div
className = "modal-overlay"
role = "dialog"
aria-modal = "true"
aria-labelledby = "future-modal-title"
onClick = {( event ) => {
if ( event . target === event . currentTarget ) {
store . closeFutureModal ();
}
}}
>
2026-03-13 07:56:20 +08:00
{ isProLoading ? (
< div
className = "modal-content large"
style = {{ padding : "40px" , textAlign : "center" }}
>
< div style = {{ color : "var(--text-muted)" }}>
{ t ( "dashboard.loading" )}
</ div >
</ div >
) : ! isPro ? (
< ProFeaturePaywall
feature = { isToday ? "today" : "future" }
onClose = { store . closeFutureModal }
/>
) : (
< div className = "modal-content large future-modal" >
< div className = "modal-header" >
2026-04-15 23:51:54 +08:00
< div className = "modal-title-stack" >
< div className = "modal-overline" >
< span >{ locale === "en-US" ? "Analysis workspace" : "分析工作台" }</ span >
< span className = "modal-overline-sep" > • </ span >
< span >{ detail . display_name . toUpperCase ()}</ span >
</ div >
< h2
id = "future-modal-title"
className = "future-modal-title-with-actions"
2026-03-13 07:56:20 +08:00
>
2026-04-15 23:51:54 +08:00
< span >
{ isToday
? t ( "future.todayTitle" , {
city : detail.display_name.toUpperCase (),
})
: t ( "future.dateTitle" , {
city : detail.display_name.toUpperCase (),
date : dateStr ,
})}
</ span >
< button
className = { clsx (
"future-refresh-btn" ,
isAnyLayerSyncing && "spinning" ,
)}
disabled = { ! isPro || isProLoading }
onClick = {() => {
if ( isToday ) {
void store . openTodayModal ( true );
return ;
}
store . openFutureModal ( dateStr , true );
}}
title = {
! isPro
? locale === "en-US"
? "Pro subscription required"
: "需要 Pro 订阅"
: locale === "en-US"
? "Refresh Data"
: "刷新数据"
}
2026-03-13 07:56:20 +08:00
>
2026-04-15 23:51:54 +08:00
< svg
width = "14"
height = "14"
viewBox = "0 0 24 24"
fill = "none"
stroke = "currentColor"
strokeWidth = "2.5"
strokeLinecap = "round"
strokeLinejoin = "round"
>
< path d = "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
< path d = "M3 3v5h5" />
</ svg >
</ button >
</ h2 >
< div className = "modal-subtitle" >
{ isToday
? locale === "en-US"
2026-04-16 00:24:30 +08:00
? "Base signal first, then probability and model layers."
: "先看基础信号,再看概率层和模型层。"
2026-04-15 23:51:54 +08:00
: locale === "en-US"
? "Forward date view with phased model and structure sync."
: "未来日期视图,模型层与结构层分阶段补齐。" }
</ div >
</ div >
2026-03-13 07:56:20 +08:00
< button
type = "button"
className = "modal-close"
aria-label = {
isToday ? t ( "future.closeTodayAria" ) : t ( "future.closeDateAria" )
2026-03-13 06:41:33 +08:00
}
2026-03-13 07:56:20 +08:00
onClick = { store . closeFutureModal }
2026-03-10 09:02:56 +08:00
>
2026-03-13 07:56:20 +08:00
×
2026-03-10 09:02:56 +08:00
</ button >
2026-03-13 07:56:20 +08:00
</ div >
2026-04-17 21:08:26 +08:00
< div
className = { clsx (
"modal-body future-modal-body" ,
isTodayBlockingRefresh && "future-modal-body-refreshing" ,
)}
>
{ isTodayBlockingRefresh && (
< div className = "future-v2-refresh-lock" role = "status" aria-live = "assertive" >
< span className = "future-v2-refresh-spinner" aria-hidden = "true" />
< div >
< strong >
{ locale === "en-US"
? "Refreshing latest intraday data"
: "正在刷新最新日内数据" }
</ strong >
< p >
{ locale === "en-US"
? "Old cached readings are temporarily locked to prevent misjudgement. The analysis will unlock after the latest anchor observation, model layer, and probability layer are ready."
: "旧缓存读数已临时锁定,避免误判。最新锚点观测、模型层和概率层就绪后会自动解锁。" }
</ p >
</ div >
</ div >
)}
2026-04-16 17:13:53 +08:00
{ isToday && (
< section className = "future-v2-meteorology-brief" >
< div className = "future-v2-meteorology-copy" >
2026-04-17 00:19:47 +08:00
< div className = "future-v2-anchor-row" >
< div className = "modal-section-kicker" >
{ locale === "en-US" ? "Professional meteorology read" : "专业气象判断" }
</ div >
< span className = "future-v2-anchor-source" >{ anchorSourceLabel }</ span >
2026-04-16 17:13:53 +08:00
</ div >
< h3 >{ meteorologyHeadline }</ h3 >
2026-04-17 00:19:47 +08:00
< p className = "future-v2-anchor-rule" >{ anchorRuleText }</ p >
2026-04-16 17:13:53 +08:00
< div className = "future-v2-meteorology-meta" >
< span >
{ locale === "en-US" ? "Confidence" : "置信度" } · { " " }
{ formatConfidenceLabel ( intradayMeteorology . confidence , locale )}
</ span >
< span >
2026-04-17 00:19:47 +08:00
{ locale === "en-US" ? "Path state" : "路径状态" } · { pathStatus }
2026-04-16 17:13:53 +08:00
</ span >
< span >
2026-04-17 00:19:47 +08:00
{ nextObservationLabel } · { nextObservationTime }
2026-04-16 17:13:53 +08:00
</ span >
</ div >
</ div >
2026-04-17 00:19:47 +08:00
< div className = "future-v2-decision-rail" >
< div className = "future-v2-decision-anchor" >
< span >{ locale === "en-US" ? "Anchor" : "锚点" }</ span >
< strong >{ settlementStationName }</ strong >
< small >{ anchorSourceLabel }</ small >
2026-04-16 17:13:53 +08:00
</ div >
2026-04-17 00:19:47 +08:00
< div className = "future-v2-decision-grid" >
< div >
< span >{ locale === "en-US" ? "Base" : "基准" }</ span >
< strong >{ baseCaseBucket || "--" }</ strong >
</ div >
< div >
< span >{ locale === "en-US" ? "Upside" : "上修" }</ span >
< strong >{ intradayMeteorology . upside_bucket || "--" }</ strong >
</ div >
< div >
< span >{ locale === "en-US" ? "Downside" : "下修" }</ span >
< strong >{ intradayMeteorology . downside_bucket || "--" }</ strong >
</ div >
< div >
< span >{ locale === "en-US" ? "Gap to base" : "距基准还差" }</ span >
< strong >{ gapToBaseText }</ strong >
</ div >
2026-04-16 17:13:53 +08:00
</ div >
</ div >
</ section >
)}
< section
className = { clsx (
"future-v2-sync-strip" ,
isToday && "future-v2-sync-strip-compact" ,
)}
aria-live = "polite"
>
2026-04-13 11:19:24 +08:00
{ syncStatusItems . map (( item ) => (
< div
key = { item . key }
2026-04-17 21:08:26 +08:00
className = { clsx (
"future-v2-sync-chip" ,
item . state === "syncing" && "syncing" ,
)}
2026-04-13 11:19:24 +08:00
>
< span className = "future-v2-sync-dot" aria-hidden = "true" />
< div className = "future-v2-sync-copy" >
< strong >{ item . label }</ strong >
< span >{ item . note }</ span >
</ div >
</ div >
))}
</ section >
2026-03-27 20:58:38 +08:00
{ isNoaaSettlement && (
2026-04-15 23:51:54 +08:00
< div className = "modal-callout modal-callout-info" >
2026-03-23 14:39:41 +08:00
{ locale === "en-US"
2026-03-27 20:58:38 +08:00
? ` ${ detail . display_name } now settles against NOAA ${ noaaStationCode } ( ${ noaaStationName } ). The market uses the highest rounded whole-degree Celsius reading in the Temp column after the day is finalized.`
: ` ${ detail . display_name } 当前按 NOAA ${ noaaStationCode } ( ${ noaaStationName } )结算。市场最终采用该日 Temp 列完成质控后的最高整度摄氏值,不按小数温度结算。` }
2026-03-23 14:39:41 +08:00
</ div >
)}
2026-03-13 07:56:20 +08:00
{ isToday ? (
< div className = "future-v2-layout" >
< aside className = "future-v2-left" >
< section className = "future-v2-card future-v2-hero-card" >
2026-04-08 11:16:51 +08:00
< div className = "future-v2-card-head" >
< h3 className = "future-v2-hero-title" >
{ locale === "en-US"
? "Anchor Status"
: "锚点状态" }
</ h3 >
< div className = "future-v2-card-kicker" >
{ locale === "en-US"
? "Settlement anchor and current clock"
: "结算锚点与当前时钟" }
</ div >
</ div >
2026-03-13 07:56:20 +08:00
< div className = "future-v2-hero-main" >
< div className = "future-v2-hero-temp" >
{ currentTempText }
</ div >
< div className = "future-v2-hero-divider" />
< div className = "future-v2-hero-weather" >
< span className = "future-v2-hero-icon" >
< WeatherIcon
emoji = { weatherSummary . weatherIcon }
size = { 42 }
/>
</ span >
< span >{ weatherSummary . weatherText }</ span >
</ div >
2026-03-10 09:02:56 +08:00
</ div >
2026-03-13 07:56:20 +08:00
< div className = "future-v2-hero-obs" >
@ { detail . current ? . obs_time || "--" }
2026-03-10 09:02:56 +08:00
</ div >
2026-03-27 23:34:48 +08:00
{ daylightProgress ? (
< div className = "future-v2-daylight" >
< div className = "future-v2-daylight-head" >
< span >
{ locale === "en-US"
? "Solar Window"
: "昼夜进度" }
</ span >
< strong >
{ locale === "en-US"
? ` ${ daylightProgress . phase } ${ Math . round ( daylightProgress . percent ) } %`
: ` ${ daylightProgress . phase } ${ Math . round ( daylightProgress . percent ) } %` }
</ strong >
</ div >
< div
className = "future-v2-daylight-bar"
style = {
{
"--daylight-progress" : ` ${ daylightProgress . percent } %` ,
} as CSSProperties & { "--daylight-progress" : string }
}
/>
< div className = "future-v2-daylight-times" >
< span >{ detail . forecast ? . sunrise || "--" }</ span >
< span >{ detail . forecast ? . sunset || "--" }</ span >
</ div >
</ div >
) : null }
2026-03-13 07:56:20 +08:00
< div className = "future-v2-mini-grid" >
< div className = "future-v2-mini-item" >
< span >
2026-04-08 11:16:51 +08:00
{ locale === "en-US" ? "High so far" : "日内已见高点" }
2026-03-13 07:56:20 +08:00
</ span >
< strong >
{ topObservedTemp ?? "--" }
{ detail . temp_symbol }
</ strong >
2026-03-10 09:02:56 +08:00
</ div >
2026-03-13 07:56:20 +08:00
< div className = "future-v2-mini-item" >
2026-03-10 09:02:56 +08:00
< span >
2026-04-08 11:16:51 +08:00
{ locale === "en-US" ? "Anchor clock" : "锚点时钟" }
2026-03-10 09:02:56 +08:00
</ span >
2026-04-08 11:16:51 +08:00
< strong >{ detail . current ? . obs_time || "--" }</ strong >
2026-03-13 07:56:20 +08:00
</ div >
2026-04-16 17:13:53 +08:00
< div className = "future-v2-mini-item" >
< span >
{ locale === "en-US" ? "Gap to base" : "距基准档" }
</ span >
< strong >
{ gapToBaseBucket != null
? ` ${ gapToBaseBucket . toFixed ( 1 ) }${ detail . temp_symbol } `
: "--" }
</ strong >
</ div >
< div className = "future-v2-mini-item" >
< span >
{ locale === "en-US" ? "Path state" : "路径状态" }
</ span >
< strong >{ pathStatus }</ strong >
</ div >
2026-03-13 07:56:20 +08:00
< div className = "future-v2-mini-item" >
< span >
2026-04-08 11:16:51 +08:00
{ locale === "en-US" ? "Sunrise" : "日出时间" }
2026-03-13 07:56:20 +08:00
</ span >
2026-04-08 11:16:51 +08:00
< strong >{ detail . forecast ? . sunrise || "--" }</ strong >
2026-03-13 07:56:20 +08:00
</ div >
< div className = "future-v2-mini-item" >
< span >
2026-04-08 11:16:51 +08:00
{ locale === "en-US" ? "Sunset" : "日落时间" }
2026-03-13 07:56:20 +08:00
</ span >
< strong >
2026-04-08 11:16:51 +08:00
{ detail . forecast ? . sunset || "--" }
2026-03-10 09:02:56 +08:00
</ strong >
</ div >
</ div >
2026-03-13 07:56:20 +08:00
</ section >
2026-03-10 09:02:56 +08:00
2026-04-13 18:42:27 +08:00
{ showDeferredTodaySections && paceView ? (
2026-04-08 11:16:51 +08:00
< section className = "future-v2-card future-v2-pace-card future-v2-focus-card" >
< div className = "future-v2-card-head" >
< h4 className = "future-v2-card-title" >
{ locale === "en-US" ? "Current Pace" : "当前节奏" }
</ h4 >
< div className = "future-v2-card-kicker" >
{ locale === "en-US"
? "Expected now vs airport anchor"
: "此刻应到 vs 机场锚点" }
</ div >
</ div >
2026-04-08 10:33:58 +08:00
< div className = "future-v2-pace-head" >
< span className = "future-v2-pace-kicker" >
{ paceView . kicker }
</ span >
< em
className = { clsx (
"future-v2-signal-tag" ,
paceView . biasTone === "cold" && "cyan" ,
paceView . biasTone === "neutral" && "blue" ,
paceView . biasTone === "warm" && "amber" ,
)}
>
{ paceView . badge }
</ em >
</ div >
< div
className = { clsx (
"future-v2-pace-delta" ,
paceView . biasTone === "cold" && "cold" ,
paceView . biasTone === "neutral" && "neutral" ,
paceView . biasTone === "warm" && "warm" ,
)}
>
{ paceView . deltaText }
</ div >
< div className = "future-v2-pace-summary" >
{ paceView . summary }
</ div >
< div className = "future-v2-pace-meter" >
< span className = "future-v2-pace-meter-midline" />
< span
className = { clsx (
"future-v2-pace-meter-fill" ,
paceView . biasTone === "cold" && "cold" ,
paceView . biasTone === "neutral" && "neutral" ,
paceView . biasTone === "warm" && "warm" ,
)}
style = {
{
"--pace-left" : ` ${ paceView . meterLeft } %` ,
"--pace-width" : ` ${ paceView . meterWidth } %` ,
} as CSSProperties & {
"--pace-left" : string ;
"--pace-width" : string ;
}
}
/>
</ div >
< div className = "future-v2-mini-grid future-v2-mini-grid-tight" >
< div className = "future-v2-mini-item" >
< span >
{ locale === "en-US" ? "Expected now" : "预期此刻" }
</ span >
< strong >
{ paceView . expectedNow . toFixed ( 1 )}
{ detail . temp_symbol }
</ strong >
</ div >
< div className = "future-v2-mini-item" >
< span >{ paceView . observedLabel }</ span >
< strong >
{ paceView . observedNow . toFixed ( 1 )}
{ detail . temp_symbol }
</ strong >
</ div >
< div className = "future-v2-mini-item" >
< span >{ paceView . paceAdjustedLabel }</ span >
< strong >
{ paceView . paceAdjustedHigh != null
? ` ${ paceView . paceAdjustedHigh . toFixed ( 1 ) }${ detail . temp_symbol } `
: "--" }
</ strong >
</ div >
< div className = "future-v2-mini-item" >
< span >
{ locale === "en-US" ? "Peak window" : "峰值窗口" }
</ span >
< strong >{ paceView . peakWindowText }</ strong >
</ div >
</ div >
2026-04-08 11:16:51 +08:00
< div className = "future-v2-pace-signal-grid" >
{[ boundaryRiskView , peakWindowStateView , networkLeadView ]
. filter (( item ) => item != null )
. map (( item ) => (
< div key = { item . label } className = "future-v2-pace-signal-card" >
< div className = "future-v2-signal-head" >
< span >{ item . label }</ span >
< em
className = { clsx (
"future-v2-signal-tag" ,
item . tone === "cyan" && "cyan" ,
item . tone === "blue" && "blue" ,
item . tone === "amber" && "amber" ,
)}
>
{ item . status }
</ em >
</ div >
< strong >{ item . value }</ strong >
< div className = "future-v2-pace-signal-note" >
{ item . note }
</ div >
</ div >
))}
</ div >
2026-04-08 10:33:58 +08:00
</ section >
2026-04-13 18:42:27 +08:00
) : isToday ? (
< section className = "future-v2-card future-v2-support-card" >
< div className = "future-v2-card-head" >
< h4 className = "future-v2-card-title" >
{ locale === "en-US" ? "Current Pace" : "当前节奏" }
</ h4 >
< div className = "future-v2-card-kicker" >
{ locale === "en-US"
? "Backfilling intraday pace context"
: "正在补齐日内节奏上下文" }
</ div >
</ div >
< div className = "future-trend-summary future-trend-summary-muted" >
{ locale === "en-US"
? "Expected-now pace, boundary risk, and airport-vs-network cues are loading in the background."
: "预期此刻节奏、边界风险和机场对比站网信号正在后台补齐。" }
</ div >
</ section >
2026-04-08 10:33:58 +08:00
) : null }
2026-03-13 07:56:20 +08:00
</ aside >
< main className = "future-v2-right" >
< section className = "future-modal-section future-v2-main-chart" >
2026-04-15 23:51:54 +08:00
< div className = "modal-section-heading" >
< div className = "modal-section-kicker" >
{ locale === "en-US" ? "Primary view" : "主视图" }
</ div >
< h3 >
{ locale === "en-US"
2026-04-17 00:19:47 +08:00
? "Today's temperature path (anchor obs + models)"
: "今日气温路径(锚点观测 + 模型)" }
2026-04-15 23:51:54 +08:00
</ h3 >
</ div >
2026-04-17 20:40:17 +08:00
< DailyTemperatureChart dateStr = { dateStr } forceToday = { isToday } />
2026-04-16 17:13:53 +08:00
< div className = "future-v2-chart-thresholds" >
< span >{ locale === "en-US" ? "Base" : "基准" } · { baseCaseBucket || "--" }</ span >
< span >{ locale === "en-US" ? "Upside" : "上修" } · { intradayMeteorology . upside_bucket || "--" }</ span >
< span >{ locale === "en-US" ? "Invalidates at" : "失效观察" } · { nextObservationTime }</ span >
</ div >
2026-03-10 09:02:56 +08:00
</ section >
2026-04-16 17:13:53 +08:00
< div className = "future-v2-meteorology-grid" >
< section className = "future-modal-section future-v2-evidence-panel" >
< div className = "modal-section-heading" >
< div className = "modal-section-kicker" >
{ locale === "en-US" ? "Evidence chain" : "气象证据链" }
</ div >
< h3 >{ locale === "en-US" ? "Signal Contributions" : "信号贡献" }</ h3 >
</ div >
< div className = "future-v2-evidence-list" >
{ meteorologySignals . length > 0 ? (
meteorologySignals . map (( signal , index ) => (
< div
key = { ` ${ signal . label || "signal" } - ${ index } ` }
className = { clsx (
"future-v2-evidence-row" ,
signalTone ( signal ),
)}
>
< div className = "future-v2-evidence-head" >
2026-04-16 17:34:39 +08:00
< strong >
{ localizedText ( locale , signal . label , signal . label_en ) || "--" }
</ strong >
2026-04-16 17:13:53 +08:00
< span >
{ formatSignalDirection ( signal . direction , locale )} · { " " }
{ formatSignalStrength ( signal . strength , locale )}
</ span >
</ div >
2026-04-16 17:34:39 +08:00
< p >
{ localizedText ( locale , signal . summary , signal . summary_en ) || "--" }
</ p >
2026-04-16 17:13:53 +08:00
</ div >
))
) : (
< div className = "future-text-block" >
{ locale === "en-US"
? "Meteorology signals are still loading."
: "气象信号仍在加载。" }
</ div >
)}
</ div >
</ section >
< section className = "future-modal-section future-v2-rule-panel" >
< div className = "modal-section-heading" >
< div className = "modal-section-kicker" >
{ locale === "en-US" ? "Failure modes" : "失效条件" }
</ div >
< h3 >{ locale === "en-US" ? "What Downgrades the Read" : "什么会让判断降级" }</ h3 >
</ div >
< ul className = "future-v2-rule-list" >
{( invalidationRules . length > 0
? invalidationRules
: [
locale === "en-US"
? "If observations stop tracking the expected curve, wait for the next refresh."
: "若实测不再贴近预期曲线,等待下一次刷新确认。" ,
]
). map (( rule , index ) => (
< li key = { ` ${ rule } - ${ index } ` }>{ rule }</ li >
))}
</ ul >
</ section >
< section className = "future-modal-section future-v2-rule-panel" >
< div className = "modal-section-heading" >
< div className = "modal-section-kicker" >
{ locale === "en-US" ? "Confirmation" : "确认条件" }
</ div >
< h3 >{ locale === "en-US" ? "What Confirms the Path" : "什么会确认主路径" }</ h3 >
</ div >
< ul className = "future-v2-rule-list" >
{( confirmationRules . length > 0
? confirmationRules
: [
locale === "en-US"
2026-04-17 00:19:47 +08:00
? airportMetarAnchor
? "Keep watching the next anchor METAR report."
: "Keep watching the next official anchor observation."
: airportMetarAnchor
? "继续观察下一次锚点 METAR 报文。"
: "继续观察下一次官方锚点观测。" ,
2026-04-16 17:13:53 +08:00
]
). map (( rule , index ) => (
< li key = { ` ${ rule } - ${ index } ` }>{ rule }</ li >
))}
</ ul >
< div className = "future-v2-model-note" >{ modelSummary }</ div >
</ section >
</ div >
2026-03-13 07:56:20 +08:00
< div className = "future-modal-grid" >
< section className = "future-modal-section" >
2026-04-15 23:51:54 +08:00
< div className = "modal-section-heading" >
< div className = "modal-section-kicker" >
2026-04-17 20:53:37 +08:00
{ locale === "en-US" ? "Probability read" : "概率判断" }
2026-04-15 23:51:54 +08:00
</ div >
< h3 >
2026-04-19 03:41:39 +08:00
{ probabilityTitle }
2026-04-15 23:51:54 +08:00
</ h3 >
</ div >
2026-04-08 11:16:51 +08:00
< div className = "future-text-block" style = {{ marginBottom : "12px" }}>
{ probabilitySummary }
</ div >
2026-03-13 07:56:20 +08:00
< div style = {{ position : "relative" , minHeight : "120px" }}>
< ProbabilityDistribution
detail = { detail }
targetDate = { dateStr }
2026-04-21 23:01:38 +08:00
marketScan = { activeMarketScan }
2026-03-13 07:56:20 +08:00
hideTitle
2026-03-10 09:02:56 +08:00
/>
</ div >
2026-03-13 07:56:20 +08:00
</ section >
< section className = "future-modal-section" >
2026-04-15 23:51:54 +08:00
< div className = "modal-section-heading" >
< div className = "modal-section-kicker" >
{ locale === "en-US" ? "Model layer" : "模型层" }
</ div >
< h3 >
{ locale === "en-US" ? "Model Range & Spread" : "模型区间与分歧" }
</ h3 >
</ div >
2026-04-08 11:16:51 +08:00
< div className = "future-text-block" style = {{ marginBottom : "12px" }}>
{ modelSummary }
</ div >
2026-03-13 07:56:20 +08:00
< ModelForecast
detail = { detail }
targetDate = { dateStr }
hideTitle
/>
</ section >
</ div >
</ main >
2026-03-09 10:36:03 +08:00
</ div >
2026-03-13 07:56:20 +08:00
) : (
<>
< div className = "history-stats" >
< div className = "h-stat-card" >
< span className = "label" >{ t ( "future.targetForecast" )}</ span >
< span className = "val" >
{ view . forecastEntry ? . max_temp ?? "--" }
{ detail . temp_symbol }
</ span >
</ div >
< div className = "h-stat-card" >
< span className = "label" >{ t ( "future.deb" )}</ span >
< span className = "val" >
{ view . deb ?? "--" }
{ detail . temp_symbol }
</ span >
</ div >
< div className = "h-stat-card" >
< span className = "label" >{ t ( "future.mu" )}</ span >
< span className = "val" >
{ view . mu != null
? ` ${ view . mu . toFixed ( 1 ) }${ detail . temp_symbol } `
: "--" }
</ span >
</ div >
< div className = "h-stat-card" >
< span className = "label" >{ t ( "future.score" )}</ span >
< span className = "val" >
{ view . front . score > 0 ? "+" : "" }
{ view . front . score }
</ span >
</ div >
</ div >
2026-03-10 04:45:40 +08:00
2026-03-10 09:02:56 +08:00
< section className = "future-modal-section" >
2026-03-13 07:56:20 +08:00
< h3 >{ t ( "future.targetTempTrend" )}</ h3 >
2026-04-17 20:40:17 +08:00
< DailyTemperatureChart dateStr = { dateStr } forceToday = { isToday } />
2026-03-10 09:02:56 +08:00
</ section >
2026-03-13 07:56:20 +08:00
< div className = "future-modal-grid" >
< section className = "future-modal-section" >
< h3 >{ t ( "future.probability" )}</ h3 >
< ProbabilityDistribution
detail = { detail }
targetDate = { dateStr }
hideTitle
/>
</ section >
< section className = "future-modal-section" >
< h3 >{ t ( "future.models" )}</ h3 >
< ModelForecast
detail = { detail }
targetDate = { dateStr }
hideTitle
/>
</ section >
</ div >
</>
)}
</ div >
2026-03-09 10:36:03 +08:00
</ div >
2026-03-13 07:56:20 +08:00
)}
2026-03-09 10:36:03 +08:00
</ div >
);
}
2026-04-15 16:30:13 +08:00