2026-04-26 09:13:18 +08:00
"use client" ;
import clsx from "clsx" ;
import { useCallback , useEffect , useRef , useState } from "react" ;
import { ModelForecast } from "@/components/dashboard/PanelSections" ;
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart" ;
2026-04-28 09:26:46 +08:00
import {
AiEvidencePanel ,
CityCardHeader ,
WeatherDecisionBand ,
} from "@/components/dashboard/scan-terminal/AiPinnedCityCardSections" ;
2026-04-26 12:39:16 +08:00
import {
buildMarketDecisionView ,
buildWeatherDecisionView ,
resolveExpectedHighCandidate ,
} from "@/components/dashboard/scan-terminal/city-card-decision-utils" ;
2026-04-26 09:13:18 +08:00
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils" ;
import { findRowForCity , getPeakWindowLabel , normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils" ;
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal" ;
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types" ;
import {
useAiCityForecast ,
useCityMarketScan ,
} from "@/components/dashboard/scan-terminal/use-ai-city-card-data" ;
import type { CityDetail , ScanOpportunityRow } from "@/lib/dashboard-types" ;
import { formatTemperatureValue , getModelView , getTodayPaceView } from "@/lib/dashboard-utils" ;
2026-04-26 10:02:54 +08:00
function toFiniteDecisionNumber ( value : unknown ) {
if ( value == null || value === "" ) return null ;
const numeric = Number ( value );
return Number . isFinite ( numeric ) ? numeric : null ;
}
2026-04-26 10:13:44 +08:00
function parseEpochMs ( value : unknown ) {
if ( value == null || value === "" ) return null ;
const numeric = Number ( value );
if ( Number . isFinite ( numeric )) return numeric > 1 _000_000_000_000 ? numeric : numeric * 1000 ;
const parsed = new Date ( String ( value ));
return Number . isNaN ( parsed . getTime ()) ? null : parsed . getTime ();
}
function formatMetarReportTime ( detail : CityDetail | null , report : string , isEn : boolean ) {
const offsetSeconds = Number ( detail ? . utc_offset_seconds );
const epochMs =
parseEpochMs ( detail ? . airport_current ? . report_time ) ??
parseEpochMs ( detail ? . airport_current ? . obs_time_epoch ) ??
parseEpochMs ( detail ? . airport_current ? . obs_time ) ??
parseEpochMs ( detail ? . current ? . report_time ) ??
parseEpochMs ( detail ? . current ? . obs_time_epoch ) ??
parseEpochMs ( detail ? . current ? . obs_time );
if ( epochMs != null ) {
const utc = new Date ( epochMs );
const zText = ` ${ String ( utc . getUTCHours ()). padStart ( 2 , "0" ) } : ${ String (
utc . getUTCMinutes (),
). padStart ( 2 , "0" ) } Z` ;
if ( Number . isFinite ( offsetSeconds )) {
const local = new Date ( epochMs + offsetSeconds * 1000 );
const localText = ` ${ String ( local . getUTCHours ()). padStart ( 2 , "0" ) } : ${ String (
local . getUTCMinutes (),
). padStart ( 2 , "0" ) } ` ;
return isEn ? ` ${ zText } / local ${ localText } ` : ` ${ zText } / 当地 ${ localText } ` ;
}
return zText ;
}
const rawToken = String ( report || "" ). match ( /\b(\d{2})(\d{2})(\d{2})Z\b/i );
if ( ! rawToken ) return "" ;
const zText = ` ${ rawToken [ 2 ] } : ${ rawToken [ 3 ] } Z` ;
if ( ! Number . isFinite ( offsetSeconds )) return zText ;
const utcMinutes = Number ( rawToken [ 2 ]) * 60 + Number ( rawToken [ 3 ]);
if ( ! Number . isFinite ( utcMinutes )) return zText ;
const localMinutes = Math . round (
(( utcMinutes + offsetSeconds / 60 ) % 1440 + 1440 ) % 1440 ,
);
const localText = ` ${ String ( Math . floor ( localMinutes / 60 )). padStart ( 2 , "0" ) } : ${ String (
localMinutes % 60 ,
). padStart ( 2 , "0" ) } ` ;
return isEn ? ` ${ zText } / local ${ localText } ` : ` ${ zText } / 当地 ${ localText } ` ;
}
function normalizeMetarReadTime ( text : string , displayTime : string , isEn : boolean ) {
if ( ! text || ! displayTime ) return text ;
const timeLabel = isEn ? `report time ${ displayTime } ` : `报文时间 ${ displayTime } ` ;
return text
. replace ( /报文时间\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi , timeLabel )
. replace ( /report time\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi , timeLabel )
. replace ( /\bat\s+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi , `at ${ displayTime } ` );
}
2026-04-27 02:18:34 +08:00
function isHkoObservationCity ( detail? : CityDetail | null ) {
const source = String (
detail ? . current ? . settlement_source ||
detail ? . settlement_station ? . settlement_source ||
"" ,
)
. trim ()
. toLowerCase ();
return source === "hko" ;
}
2026-04-28 06:52:00 +08:00
type StatusTagTone = "green" | "blue" | "amber" | "red" | "muted" ;
type StatusTag = {
label : string ;
tone : StatusTagTone ;
};
function formatFreshnessAge ( value : unknown , isEn : boolean ) {
const minutes = Number ( value );
if ( ! Number . isFinite ( minutes ) || minutes < 0 ) return "" ;
if ( minutes < 1 ) return isEn ? "just now" : "刚刚" ;
if ( minutes < 60 ) {
const rounded = Math . max ( 1 , Math . round ( minutes ));
return isEn ? ` ${ rounded } m ago` : ` ${ rounded } 分钟前` ;
}
const hours = Math . floor ( minutes / 60 );
const remaining = Math . round ( minutes % 60 );
if ( remaining <= 0 ) return isEn ? ` ${ hours } h ago` : ` ${ hours } 小时前` ;
return isEn ? ` ${ hours } h ${ remaining } m ago` : ` ${ hours } 小时 ${ remaining } 分钟前` ;
}
2026-04-28 07:33:46 +08:00
function formatUpdateTime ( value : unknown , locale : string ) {
const epochMs = parseEpochMs ( value );
if ( epochMs == null ) return "" ;
const date = new Date ( epochMs );
const now = new Date ();
const sameDay = date . toDateString () === now . toDateString ();
const time = date . toLocaleTimeString ( locale === "en-US" ? "en-US" : "zh-CN" , {
hour : "2-digit" ,
minute : "2-digit" ,
});
if ( locale === "en-US" ) {
const day = sameDay
? "today"
: date . toLocaleDateString ( "en-US" , { month : "short" , day : "numeric" });
return ` ${ day } ${ time } updated` ;
}
const day = sameDay
? "今日"
: date . toLocaleDateString ( "zh-CN" , { month : "numeric" , day : "numeric" });
return ` ${ day } ${ time } 更新` ;
}
function buildObservationFreshnessValue ({
2026-04-28 06:52:00 +08:00
detail ,
displayTime ,
isEn ,
isHkoObservation ,
} : {
detail : CityDetail | null ;
displayTime : string ;
isEn : boolean ;
isHkoObservation : boolean ;
}) {
const stale = Boolean (
detail ? . metar_status ? . stale_for_today ||
detail ? . airport_current ? . stale_for_today ||
detail ? . current ? . observation_status === "stale" ,
);
2026-04-28 07:33:46 +08:00
if ( stale ) {
return isEn ? "stale; background only" : "已过旧,仅作背景参考" ;
}
2026-04-28 06:52:00 +08:00
const ageLabel = formatFreshnessAge (
isHkoObservation ? detail?.current?.obs_age_min : detail?.airport_current?.obs_age_min ?? detail ? . current ? . obs_age_min ,
isEn ,
);
2026-04-28 07:33:46 +08:00
if ( ageLabel ) return ageLabel ;
if ( displayTime ) return displayTime ;
return isEn ? "time pending" : "时间待确认" ;
}
function buildModelFreshnessValue ( detail : CityDetail | null , locale : string , isEn : boolean ) {
return (
formatUpdateTime ( detail ? . updated_at , locale ) ||
( isEn ? "latest run loaded" : "已加载最新模型" )
);
}
function buildMarketFreshnessValue ({
isEn ,
marketScan ,
marketStatus ,
} : {
isEn : boolean ;
marketScan : ReturnType < typeof useCityMarketScan >[ "marketScan" ];
marketStatus : ReturnType < typeof useCityMarketScan >[ "marketStatus" ];
}) {
if ( marketStatus === "loading" ) return isEn ? "syncing" : "同步中" ;
2026-04-28 07:48:47 +08:00
if ( ! marketScan ? . available ) return isEn ? "temporarily unavailable" : "暂不可用" ;
2026-04-28 07:33:46 +08:00
const quoteAgeMs = Number (
marketScan . quote_age_ms ??
marketScan . yes_token ? . quote_age_ms ??
marketScan . no_token ? . quote_age_ms ,
);
if ( Number . isFinite ( quoteAgeMs ) && quoteAgeMs >= 0 ) {
return formatFreshnessAge ( quoteAgeMs / 60 _000 , isEn );
}
return isEn ? "synced" : "已同步" ;
2026-04-28 06:52:00 +08:00
}
function uniqueStatusTags ( tags : Array < StatusTag | null | undefined >) {
const seen = new Set < string >();
return tags . filter (( tag ) : tag is StatusTag => {
if ( ! tag ? . label || seen . has ( tag . label )) return false ;
seen . add ( tag . label );
return true ;
});
}
2026-04-26 09:13:18 +08:00
function AiPinnedCityCard ({
item ,
detail ,
row ,
locale ,
collapsed ,
removing ,
2026-04-27 00:19:52 +08:00
onRefreshCityDetail ,
2026-04-26 09:13:18 +08:00
onRemove ,
onToggleCollapsed ,
} : {
item : AiPinnedCity ;
detail : CityDetail | null ;
row : ScanOpportunityRow | null ;
locale : string ;
collapsed : boolean ;
removing? : boolean ;
2026-04-27 00:19:52 +08:00
onRefreshCityDetail : ( cityName : string ) => Promise < void >;
2026-04-26 09:13:18 +08:00
onRemove : () => void ;
onToggleCollapsed : () => void ;
}) {
const isEn = locale === "en-US" ;
const displayName =
detail ? . display_name ||
row ? . city_display_name ||
row ? . display_name ||
item . displayName ||
item . cityName ;
const tempSymbol = detail ? . temp_symbol || row ? . temp_symbol || "°C" ;
const modelView = detail ? getModelView ( detail , detail . local_date ) : null ;
const modelEntries = modelView
? Object . entries ( modelView . models || {})
. map (([ name , value ]) => [ name , Number ( value )] as const )
. filter (([, value ]) => Number . isFinite ( value ))
: [];
const modelValues = modelEntries . map (([, value ]) => value );
const modelMin = modelValues . length ? Math . min (... modelValues ) : null ;
const modelMax = modelValues . length ? Math . max (... modelValues ) : null ;
const paceView = detail ? getTodayPaceView ( detail , locale as "zh-CN" | "en-US" ) : null ;
const peakWindow =
paceView ? . peakWindowText ||
( row ? getPeakWindowLabel ( row ) : null ) ||
"--" ;
const deb = detail ? . deb ? . prediction ?? row ? . deb_prediction ?? null ;
2026-04-27 02:18:34 +08:00
const isHkoObservation = isHkoObservationCity ( detail );
2026-04-26 09:13:18 +08:00
const currentTemp =
2026-04-27 02:18:34 +08:00
( isHkoObservation
? detail ? . current ? . temp ?? row?.current_temp
: detail?.airport_primary?.temp ??
detail ? . airport_current ? . temp ??
detail ? . current ? . temp ??
row ? . current_temp ) ?? null ;
2026-04-26 12:39:16 +08:00
const debNumber = toFiniteDecisionNumber ( deb );
const currentTempNumber = toFiniteDecisionNumber ( currentTemp );
2026-04-26 09:13:18 +08:00
const modelRange =
modelMin != null && modelMax != null
? ` ${ formatTemperatureValue ( modelMin , tempSymbol , { digits : 1 } )} ~ ${ formatTemperatureValue ( modelMax , tempSymbol , { digits : 1 } )}`
: "--" ;
const paceTone = paceView ? . biasTone || "neutral" ;
const paceText =
paceView ? . summary ||
( isEn
? "Waiting for intraday observations to compare against the DEB path."
: "等待更多日内实测,用来对照 DEB 预测路径。" );
2026-04-27 02:18:34 +08:00
const report = isHkoObservation
? ""
: detail ? . current ? . raw_metar || detail ? . airport_current ? . raw_metar || "" ;
2026-04-26 10:13:44 +08:00
const metarReportTimeDisplay = formatMetarReportTime ( detail , report , isEn );
2026-04-27 02:18:34 +08:00
const observationStation = isHkoObservation
? detail ? . current ? . station_name ||
detail ? . current ? . station_code ||
detail ? . settlement_station ? . settlement_station_label ||
detail ? . settlement_station ? . settlement_station_code ||
"香港天文台"
: detail ? . risk ? . icao ||
detail ? . current ? . station_code ||
detail ? . airport_current ? . station_code ||
detail ? . airport_primary ? . station_code ||
"" ;
const observationSourceZh = isHkoObservation ? "香港天文台观测" : "METAR 实测" ;
const observationSourceEn = isHkoObservation ? "HKO observations" : "METAR observations" ;
const rawObservationText = isHkoObservation
? ` ${ isEn ? "Observation source" : "观测来源" } : ${ observationStation || ( isEn ? "Hong Kong Observatory" : "香港天文台" ) }${ metarReportTimeDisplay ? `, ${ metarReportTimeDisplay } ` : "" } `
: report
? ` ${ isEn ? "Raw METAR" : "原始 METAR" } : ${ ` ${ observationStation } ${ report } ` . trim () } `
: isEn
? "Raw METAR: unavailable."
: "原始 METAR:暂无。" ;
2026-04-26 09:13:18 +08:00
const detailCityName = detail ? . name || item . cityName ;
2026-04-27 00:19:52 +08:00
const [ refreshingDetail , setRefreshingDetail ] = useState ( false );
2026-04-26 09:13:18 +08:00
const { aiForecast , refreshAiForecast } = useAiCityForecast ({
detail ,
detailCityName ,
2026-04-26 13:50:46 +08:00
enabled : Boolean ( detail ),
2026-04-26 09:13:18 +08:00
isEn ,
locale ,
report ,
});
const { marketScan , marketStatus } = useCityMarketScan ({
detail ,
detailCityName ,
2026-04-26 13:50:46 +08:00
enabled : Boolean ( detail ),
2026-04-26 09:13:18 +08:00
});
2026-04-27 00:19:52 +08:00
const isRefreshing = refreshingDetail || aiForecast . status === "loading" ;
2026-04-28 07:48:47 +08:00
const [ isCompactCard , setIsCompactCard ] = useState ( false );
useEffect (() => {
if ( typeof window === "undefined" || ! window . matchMedia ) return ;
const media = window . matchMedia ( "(max-width: 820px)" );
const syncCompactMode = () => setIsCompactCard ( media . matches );
syncCompactMode ();
media . addEventListener ( "change" , syncCompactMode );
return () => media . removeEventListener ( "change" , syncCompactMode );
}, []);
2026-04-26 09:13:18 +08:00
const aiCityForecast = aiForecast . payload ? . city_forecast || null ;
2026-04-26 10:13:44 +08:00
const localizedFinalJudgmentRaw =
2026-04-26 09:13:18 +08:00
( isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh ) ||
( isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh ) ||
"" ;
2026-04-26 10:13:44 +08:00
const localizedMetarReadRaw =
2026-04-26 09:13:18 +08:00
( isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh ) ||
"" ;
2026-04-26 10:13:44 +08:00
const localizedReasoningRaw =
2026-04-26 09:13:18 +08:00
( isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh ) ||
"" ;
2026-04-26 10:13:44 +08:00
const localizedFinalJudgment = normalizeMetarReadTime (
localizedFinalJudgmentRaw ,
metarReportTimeDisplay ,
isEn ,
);
const localizedMetarRead = normalizeMetarReadTime (
localizedMetarReadRaw ,
metarReportTimeDisplay ,
isEn ,
);
const localizedReasoning = normalizeMetarReadTime (
localizedReasoningRaw ,
metarReportTimeDisplay ,
isEn ,
);
2026-04-26 09:13:18 +08:00
const localizedModelNote =
( isEn
? aiCityForecast?.model_cluster_note_en
: aiCityForecast?.model_cluster_note_zh ) || "" ;
const modelPreview = modelEntries
. slice ( 0 , 4 )
. map (([ name , value ]) => ` ${ name } ${ formatTemperatureValue ( value , tempSymbol , { digits : 1 } )}` )
. join ( isEn ? " / " : " / " );
const localModelSupportNote = modelEntries . length
? isEn
? modelEntries . length <= 2
2026-04-27 02:18:34 +08:00
? `Model support is sparse: only ${ modelEntries . length } sources are available ${ modelPreview ? ` ( ${ modelPreview } )` : "" } , so the read should lean more on DEB path and ${ observationSourceEn } .`
2026-04-26 09:13:18 +08:00
: `Model support: ${ modelEntries . length } sources cluster between ${ modelRange } ; ${ modelPreview } .`
: modelEntries . length <= 2
2026-04-27 02:18:34 +08:00
? `多模型支撑偏少:当前只有 ${ modelEntries . length } 个模型 ${ modelPreview ? `( ${ modelPreview } ) ` : "" } ,需要更重视 DEB 路径和 ${ observationSourceZh } 。`
2026-04-26 09:13:18 +08:00
: `多模型支撑: ${ modelEntries . length } 个模型集中在 ${ modelRange } ,代表模型为 ${ modelPreview } 。`
: isEn
2026-04-27 02:18:34 +08:00
? `Model support is unavailable, so this city must rely on DEB path and ${ observationSourceEn } .`
: `暂无可用多模型支撑,需要主要参考 DEB 路径和 ${ observationSourceZh } 。` ;
2026-04-26 10:02:54 +08:00
const aiPredictedMax = toFiniteDecisionNumber ( aiCityForecast ? . predicted_max );
2026-04-26 12:39:16 +08:00
const decisionExpectedHighNumber = resolveExpectedHighCandidate ({
aiPredictedMax ,
currentTemp : currentTempNumber ,
deb : debNumber ,
modelMax ,
modelMin ,
paceAdjustedHigh : paceView?.paceAdjustedHigh ?? null ,
});
2026-04-26 09:13:18 +08:00
const decisionView = buildWeatherDecisionView ({
aiCityForecast ,
2026-04-26 12:39:16 +08:00
currentTemp : currentTempNumber ,
deb : debNumber ,
2026-04-26 09:13:18 +08:00
isEn ,
localModelSupportNote ,
modelEntries ,
modelMax ,
modelMin ,
paceTone ,
paceView ,
peakWindow ,
tempSymbol ,
});
2026-04-28 07:48:47 +08:00
const expectedHighText =
decisionExpectedHighNumber != null
? formatTemperatureValue ( decisionExpectedHighNumber , tempSymbol , { digits : 1 })
: "--" ;
const currentTempText =
currentTempNumber != null
? formatTemperatureValue ( currentTempNumber , tempSymbol , { digits : 1 })
: "--" ;
2026-04-28 09:26:46 +08:00
const debText =
debNumber != null
? formatTemperatureValue ( debNumber , tempSymbol , { digits : 1 })
: "--" ;
2026-04-26 09:13:18 +08:00
const marketDecisionView = buildMarketDecisionView ({
expectedHigh : decisionExpectedHighNumber ,
isEn ,
marketScan ,
marketStatus ,
tempSymbol ,
});
2026-04-28 06:52:00 +08:00
const aiMeta = aiCityForecast ? . _polyweather_meta || null ;
const guardReason = aiMeta ? . deterministic_guard_reason || {};
const observationStale = Boolean (
detail ? . metar_status ? . stale_for_today ||
detail ? . airport_current ? . stale_for_today ||
detail ? . current ? . observation_status === "stale" ||
guardReason . observation_stale ,
);
const observedHighBreak = Boolean (
guardReason . observed_high_break ||
( currentTempNumber != null &&
modelMax != null &&
currentTempNumber > modelMax + 0.2 ),
);
const observedLowBreak = Boolean ( guardReason . observed_low_break );
const observedLowLag = Boolean ( guardReason . observed_low_lag );
const peakHasPassed = Boolean (
guardReason . peak_has_passed ||
[ "past" , "post_peak" , "after_peak" ]. includes (
String (( row as { window_phase? : string | null } | null ) ? . window_phase || "" ). toLowerCase (),
),
);
2026-04-28 07:17:24 +08:00
const modelSpread = modelMax != null && modelMin != null ? modelMax - modelMin : null ;
const modelHighlyConsistent =
modelEntries . length >= 4 && modelSpread != null && modelSpread <= 2 ;
const needsNextBulletin =
! observationStale &&
! observedHighBreak &&
! observedLowBreak &&
! peakHasPassed &&
( observedLowLag || paceTone === "neutral" || aiForecast . status === "loading" );
2026-04-28 07:21:59 +08:00
const aiRuleEvidenceMode = Boolean (
aiForecast . status === "failed" ||
( aiForecast . status === "ready" && ! aiCityForecast ) ||
aiForecast . payload ? . degraded ||
aiMeta ? . fallback ,
);
const aiReadInProgressText = isEn
? isHkoObservation
? "Fast read is ready; AI is adding HKO observation details..."
: "Fast read is ready; AI is adding airport bulletin details..."
: isHkoObservation
? "快速判断已完成,AI 正在补充香港天文台观测细节…"
: "快速判断已完成,AI 正在补充机场报文细节…" ;
const aiReadCompleteText = isEn
? isHkoObservation
? "AI HKO observation read is complete."
: "AI airport bulletin read is complete."
: isHkoObservation
? "AI 香港天文台观测解读已完成"
: "AI 机场报文解读已完成" ;
const aiRuleEvidenceText = isEn
? "AI read did not return completely; rule evidence is being used."
: "AI 解读未完整返回,当前使用规则证据" ;
2026-04-28 06:52:00 +08:00
const aiStatusLabel =
aiForecast . status === "loading"
? isEn
2026-04-28 07:21:59 +08:00
? "Fast read ready"
: "快速判断已完成"
2026-04-28 06:52:00 +08:00
: aiForecast . status === "ready" && aiCityForecast
2026-04-28 07:21:59 +08:00
? aiRuleEvidenceMode
2026-04-28 06:52:00 +08:00
? isEn
2026-04-28 07:21:59 +08:00
? "Rule evidence"
: "规则证据模式"
2026-04-28 06:52:00 +08:00
: isEn
2026-04-28 07:21:59 +08:00
? "AI read complete"
: "AI 解读已完成"
: aiRuleEvidenceMode
2026-04-28 06:52:00 +08:00
? isEn
2026-04-28 07:21:59 +08:00
? "Rule evidence"
: "规则证据模式"
2026-04-28 06:52:00 +08:00
: isEn
? "AI pending"
: "AI 待返回" ;
const aiStatusTone : StatusTagTone =
aiForecast . status === "loading"
? "blue"
: aiForecast . status === "ready" && aiCityForecast
2026-04-28 07:21:59 +08:00
? aiRuleEvidenceMode
2026-04-28 06:52:00 +08:00
? "amber"
: "green"
2026-04-28 07:21:59 +08:00
: aiRuleEvidenceMode
? "amber"
2026-04-28 06:52:00 +08:00
: "muted" ;
const marketStatusTone : StatusTagTone =
marketDecisionView . status === "ready"
? "green"
: marketDecisionView . status === "loading"
? "blue"
: "muted" ;
2026-04-28 07:33:46 +08:00
const dataFreshnessRows = [
{
label : isHkoObservation ? ( isEn ? "HKO" : "天文台" ) : "METAR" ,
value : buildObservationFreshnessValue ({
detail ,
displayTime : metarReportTimeDisplay ,
isEn ,
isHkoObservation ,
}),
tone : observationStale ? "stale" : "fresh" ,
},
{
label : isEn ? "Models" : "模型" ,
value : buildModelFreshnessValue ( detail , locale , isEn ),
tone : "fresh" ,
},
{
label : isEn ? "Market" : "市场价格" ,
value : buildMarketFreshnessValue ({ isEn , marketScan , marketStatus }),
tone :
marketDecisionView.status === "ready"
? "fresh"
: marketDecisionView . status === "loading"
? "loading"
: "stale" ,
},
];
const freshnessSeparator = isEn ? ": " : ": " ;
2026-04-28 06:52:00 +08:00
const statusTags = uniqueStatusTags ([
observedHighBreak
? {
2026-04-28 07:17:24 +08:00
label : isEn ? "Observed breakout" : "实测突破" ,
2026-04-28 06:52:00 +08:00
tone : "red" ,
}
: null ,
peakHasPassed
? {
label : isEn ? "Peak window passed" : "峰值窗口已过" ,
tone : "muted" ,
}
: null ,
2026-04-28 07:17:24 +08:00
observationStale
? {
label : isEn
? isHkoObservation
? "HKO stale"
: "METAR stale"
: isHkoObservation
? "观测过旧"
: "METAR 过旧" ,
tone : "amber" ,
}
: null ,
observedLowBreak
? {
label : isEn ? "Peak revised down" : "峰值下修" ,
tone : "blue" ,
}
: null ,
aiForecast . status === "loading"
? {
2026-04-28 07:21:59 +08:00
label : isEn ? "Fast read ready" : "快速判断已完成" ,
2026-04-28 07:17:24 +08:00
tone : aiStatusTone ,
}
: null ,
marketDecisionView . status === "unavailable"
? {
2026-04-28 07:48:47 +08:00
label : isEn ? "Market unavailable" : "市场价暂不可用" ,
2026-04-28 07:17:24 +08:00
tone : marketStatusTone ,
}
: null ,
modelHighlyConsistent
? {
label : isEn ? "Models agree" : "模型高度一致" ,
tone : "green" ,
}
: null ,
observedLowLag || needsNextBulletin
? {
label : isEn ? "Wait next report" : "需要等待下一报文" ,
tone : "amber" ,
}
: null ,
]). slice ( 0 , 3 );
2026-04-26 09:13:18 +08:00
const localizedRisksRaw =
( isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh ) || [];
const localizedRisks = Array . isArray ( localizedRisksRaw )
? localizedRisksRaw
: localizedRisksRaw
? [ String ( localizedRisksRaw )]
: [];
const aiBullets = [
2026-04-27 00:19:52 +08:00
localizedMetarRead ,
2026-04-26 09:13:18 +08:00
localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "" ,
localizedModelNote || localModelSupportNote ,
... localizedRisks ,
]. filter (( line ) => String ( line || "" ). trim ());
const fallbackAiReason =
( isEn ? aiForecast.payload?.reason_en : aiForecast.payload?.reason_zh ) ||
aiForecast . payload ? . reason ||
"" ;
2026-04-28 07:48:47 +08:00
const decisionWhyText = observedHighBreak
? isEn
? "Worth watching now: observation has broken above the model range."
: "当前值得关注:实测已突破模型上沿。"
: peakHasPassed
? isEn
? "Avoid chasing now: peak window has passed; wait to confirm no new high."
: "当前不宜追高:峰值窗口已过,等待确认是否还有新高。"
: observationStale
? isEn
? "Use as background only: observation is stale and needs the next report."
: "当前仅作背景:观测已过旧,需要下一报文确认。"
: marketDecisionView . status === "unavailable"
? isEn
? "Weather evidence remains usable, but no tradable quote is available yet."
: "当前可参考天气:暂无可交易价格。"
: modelHighlyConsistent
? isEn
? "Worth watching now: models agree; wait for observation confirmation."
: "当前值得关注:模型高度一致,等待实测确认。"
: needsNextBulletin
? isEn
? "Wait for confirmation: the next bulletin should decide direction."
: "当前建议等待:下一报文更适合决定方向。"
: isEn
? "Watch the peak window and compare observations against the expected high."
: "当前重点:盯住峰值窗口,把实测与预计高点对照。" ;
const marketLineText =
marketDecisionView . status === "ready"
? ` ${ marketDecisionView . bucketLabel } · ${ marketDecisionView . priceText } `
: marketDecisionView . status === "loading"
? isEn
? "syncing"
: "同步中"
: isEn
? "temporarily unavailable"
: "暂不可用" ;
2026-04-26 09:13:18 +08:00
const collapseId = `ai-city-body- ${ normalizeCityKey ( item . cityName ) || item . addedAt } ` ;
return (
< article className = { clsx ( "scan-ai-city-card" , collapsed && "collapsed" , removing && "removing" )}>
2026-04-28 09:26:46 +08:00
< CityCardHeader
aiStatusLabel = { aiStatusLabel }
aiStatusTone = { aiStatusTone }
collapseId = { collapseId }
collapsed = { collapsed }
currentTempText = { currentTempText }
dataFreshnessRows = { dataFreshnessRows }
debText = { debText }
detailLocalTime = { detail ? . local_time }
displayName = { displayName }
expectedHighText = { expectedHighText }
freshnessSeparator = { freshnessSeparator }
isEn = { isEn }
isRefreshing = { isRefreshing }
modelRange = { modelRange }
onRefresh = {( event ) => {
event . preventDefault ();
event . stopPropagation ();
if ( refreshingDetail ) return ;
setRefreshingDetail ( true );
void onRefreshCityDetail ( detailCityName )
. catch (() => {})
. finally (() => {
refreshAiForecast ();
setRefreshingDetail ( false );
});
}}
onRemove = {( event ) => {
event . preventDefault ();
event . stopPropagation ();
onRemove ();
}}
onToggleCollapsed = { onToggleCollapsed }
peakWindow = { peakWindow }
removing = { removing }
rowLocalTime = { row ? . local_time }
statusTags = { statusTags }
/>
2026-04-26 09:13:18 +08:00
{ detail && ! collapsed ? (
< div className = "scan-ai-city-body" id = { collapseId }>
2026-04-28 09:26:46 +08:00
< WeatherDecisionBand
currentTempText = { currentTempText }
decisionView = { decisionView }
decisionWhyText = { decisionWhyText }
isEn = { isEn }
longText = { localizedFinalJudgment || paceText }
marketDecisionView = { marketDecisionView }
marketLineText = { marketLineText }
paceDeltaText = { paceView ? . deltaText || "--" }
peakWindow = { peakWindow }
/>
2026-04-26 09:13:18 +08:00
< div className = "scan-ai-city-analysis-grid" >
< AiCityTemperatureChart detail = { detail } />
2026-04-28 09:26:46 +08:00
< AiEvidencePanel
aiBullets = { aiBullets }
aiCityForecast = { aiCityForecast }
aiForecast = { aiForecast }
aiReadCompleteText = { aiReadCompleteText }
aiReadInProgressText = { aiReadInProgressText }
aiRuleEvidenceMode = { aiRuleEvidenceMode }
aiRuleEvidenceText = { aiRuleEvidenceText }
fallbackAiReason = { fallbackAiReason }
isCompactCard = { isCompactCard }
isEn = { isEn }
isHkoObservation = { isHkoObservation }
localModelSupportNote = { localModelSupportNote }
localizedFinalJudgment = { localizedFinalJudgment }
rawObservationText = { rawObservationText }
/>
2026-04-26 09:13:18 +08:00
</ div >
< section className = "scan-ai-city-section models" >
< div className = "scan-ai-city-section-title" >
{ isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑" }
</ div >
< ModelForecast detail = { detail } targetDate = { detail . local_date } hideTitle />
</ section >
</ div >
) : ! detail ? (
< div className = "scan-ai-city-loading" >
< LoadingSignal
title = { isEn ? "Loading city decision data" : "正在加载城市决策数据" }
description = {
isEn
2026-04-27 02:18:34 +08:00
? isHkoObservation
? "Hydrating today’ s model stack, HKO observation context and market layer."
: "Hydrating today’ s model stack, METAR context and market layer."
: isHkoObservation
? "正在补全今日模型、香港天文台观测和市场价格层。"
: "正在补全今日模型、机场报文和市场价格层。"
2026-04-26 09:13:18 +08:00
}
compact
/>
</ div >
) : null }
</ article >
);
}
export function AiPinnedForecastView ({
items ,
rows ,
detailsByName ,
locale ,
2026-04-27 00:19:52 +08:00
onRefreshCityDetail ,
2026-04-26 09:13:18 +08:00
onRemoveCity ,
} : {
items : AiPinnedCity [];
rows : ScanOpportunityRow [];
detailsByName : Record < string , CityDetail >;
locale : string ;
2026-04-27 00:19:52 +08:00
onRefreshCityDetail : ( cityName : string ) => Promise < void >;
2026-04-26 09:13:18 +08:00
onRemoveCity : ( cityName : string ) => void ;
}) {
const isEn = locale === "en-US" ;
const [ collapsedCities , setCollapsedCities ] = useState < Set < string >>(
() => new Set (),
);
const [ removingCities , setRemovingCities ] = useState < Set < string >>(
() => new Set (),
);
const knownCityKeysRef = useRef < Set < string >>( new Set ());
const removeTimersRef = useRef < Map < string , ReturnType < typeof setTimeout >>>( new Map ());
useEffect (() => {
const activeKeys = new Set (
items . map (( item ) => normalizeCityKey ( item . cityName ) || item . cityName ),
);
setCollapsedCities (( current ) => {
const next = new Set < string >();
let changed = false ;
current . forEach (( key ) => {
if ( activeKeys . has ( key )) {
next . add ( key );
} else {
changed = true ;
}
});
items . forEach (( item ) => {
const stableKey = normalizeCityKey ( item . cityName ) || item . cityName ;
if ( ! knownCityKeysRef . current . has ( stableKey )) {
changed = true ;
}
});
return changed ? next : current ;
});
knownCityKeysRef . current = activeKeys ;
}, [ items ]);
useEffect (() => {
return () => {
removeTimersRef . current . forEach (( timer ) => clearTimeout ( timer ));
removeTimersRef . current . clear ();
};
}, []);
const removeCityWithMotion = useCallback (
( item : AiPinnedCity , stableKey : string ) => {
if ( removeTimersRef . current . has ( stableKey )) return ;
setRemovingCities (( current ) => {
const next = new Set ( current );
next . add ( stableKey );
return next ;
});
const timer = setTimeout (() => {
onRemoveCity ( item . cityName );
setRemovingCities (( current ) => {
const next = new Set ( current );
next . delete ( stableKey );
return next ;
});
removeTimersRef . current . delete ( stableKey );
}, 260 );
removeTimersRef . current . set ( stableKey , timer );
},
[ onRemoveCity ],
);
if ( ! items . length ) {
return (
< div className = "scan-ai-workspace empty" >
< div className = "scan-empty-state" >
< div className = "scan-empty-title" >
{ isEn ? "Click a city on the map" : "从分布视图点击城市" }
</ div >
< div className = "scan-empty-copy" >
{ isEn
? "Selected cities will appear here as deep analysis blocks."
: "被点击的城市会加入深度分析页,并保留为城市分析区块。" }
</ div >
</ div >
</ div >
);
}
return (
< div className = "scan-ai-workspace" >
< div className = "scan-ai-workspace-head" >
< div >
< span >{ isEn ? "Selected city workspace" : "城市分析工作区" }</ span >
< strong >
{ isEn
? ` ${ items . length } cities under deep analysis`
: ` ${ items . length } 个城市正在深度分析` }
</ strong >
</ div >
< p >
{ isEn
? "Map clicks add cities here. City analysis stays here until you remove it."
: "地图点击会把城市加入这里;城市分析会保留,直到你手动移除。" }
</ p >
</ div >
< div className = "scan-ai-city-stack" >
{ items . map (( item ) => {
const detail = findDetailForCity ( detailsByName , item . cityName );
const row = findRowForCity ( rows , item . cityName );
const key = normalizeCityKey ( item . cityName );
const stableKey = key || item . cityName ;
const isKnownCity = knownCityKeysRef . current . has ( stableKey );
return (
< AiPinnedCityCard
key = { stableKey }
item = { item }
detail = { detail }
row = { row }
locale = { locale }
collapsed = { ! isKnownCity || collapsedCities . has ( stableKey )}
removing = { removingCities . has ( stableKey )}
2026-04-27 00:19:52 +08:00
onRefreshCityDetail = { onRefreshCityDetail }
2026-04-26 09:13:18 +08:00
onRemove = {() => removeCityWithMotion ( item , stableKey )}
onToggleCollapsed = {() => {
setCollapsedCities (( current ) => {
const next = new Set ( current );
if ( next . has ( stableKey )) {
next . delete ( stableKey );
} else {
next . add ( stableKey );
}
return next ;
});
}}
/>
);
})}
</ div >
</ div >
);
}