"use client"; import clsx from "clsx"; import { ChevronDown, RefreshCw, X } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { ModelForecast } from "@/components/dashboard/PanelSections"; import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart"; import { buildMarketDecisionView, buildWeatherDecisionView, resolveExpectedHighCandidate, } from "@/components/dashboard/scan-terminal/city-card-decision-utils"; 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"; function toFiniteDecisionNumber(value: unknown) { if (value == null || value === "") return null; const numeric = Number(value); return Number.isFinite(numeric) ? numeric : null; } 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}`); } function isHkoObservationCity(detail?: CityDetail | null) { const source = String( detail?.current?.settlement_source || detail?.settlement_station?.settlement_source || "", ) .trim() .toLowerCase(); return source === "hko"; } function AiPinnedCityCard({ item, detail, row, locale, collapsed, removing, onRefreshCityDetail, onRemove, onToggleCollapsed, }: { item: AiPinnedCity; detail: CityDetail | null; row: ScanOpportunityRow | null; locale: string; collapsed: boolean; removing?: boolean; onRefreshCityDetail: (cityName: string) => Promise; 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; const isHkoObservation = isHkoObservationCity(detail); const currentTemp = (isHkoObservation ? detail?.current?.temp ?? row?.current_temp : detail?.airport_primary?.temp ?? detail?.airport_current?.temp ?? detail?.current?.temp ?? row?.current_temp) ?? null; const debNumber = toFiniteDecisionNumber(deb); const currentTempNumber = toFiniteDecisionNumber(currentTemp); 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 预测路径。"); const report = isHkoObservation ? "" : detail?.current?.raw_metar || detail?.airport_current?.raw_metar || ""; const metarReportTimeDisplay = formatMetarReportTime(detail, report, isEn); 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:暂无。"; const detailCityName = detail?.name || item.cityName; const [refreshingDetail, setRefreshingDetail] = useState(false); const { aiForecast, refreshAiForecast } = useAiCityForecast({ detail, detailCityName, enabled: Boolean(detail), isEn, locale, report, }); const { marketScan, marketStatus } = useCityMarketScan({ detail, detailCityName, enabled: Boolean(detail), }); const isRefreshing = refreshingDetail || aiForecast.status === "loading"; const aiCityForecast = aiForecast.payload?.city_forecast || null; const localizedFinalJudgmentRaw = (isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) || (isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) || ""; const localizedMetarReadRaw = (isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) || ""; const localizedReasoningRaw = (isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) || ""; const localizedFinalJudgment = normalizeMetarReadTime( localizedFinalJudgmentRaw, metarReportTimeDisplay, isEn, ); const localizedMetarRead = normalizeMetarReadTime( localizedMetarReadRaw, metarReportTimeDisplay, isEn, ); const localizedReasoning = normalizeMetarReadTime( localizedReasoningRaw, metarReportTimeDisplay, isEn, ); 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 ? `Model support is sparse: only ${modelEntries.length} sources are available${modelPreview ? ` (${modelPreview})` : ""}, so the read should lean more on DEB path and ${observationSourceEn}.` : `Model support: ${modelEntries.length} sources cluster between ${modelRange}; ${modelPreview}.` : modelEntries.length <= 2 ? `多模型支撑偏少:当前只有 ${modelEntries.length} 个模型${modelPreview ? `(${modelPreview})` : ""},需要更重视 DEB 路径和${observationSourceZh}。` : `多模型支撑:${modelEntries.length} 个模型集中在 ${modelRange},代表模型为 ${modelPreview}。` : isEn ? `Model support is unavailable, so this city must rely on DEB path and ${observationSourceEn}.` : `暂无可用多模型支撑,需要主要参考 DEB 路径和${observationSourceZh}。`; const aiPredictedMax = toFiniteDecisionNumber(aiCityForecast?.predicted_max); const decisionExpectedHighNumber = resolveExpectedHighCandidate({ aiPredictedMax, currentTemp: currentTempNumber, deb: debNumber, modelMax, modelMin, paceAdjustedHigh: paceView?.paceAdjustedHigh ?? null, }); const decisionView = buildWeatherDecisionView({ aiCityForecast, currentTemp: currentTempNumber, deb: debNumber, isEn, localModelSupportNote, modelEntries, modelMax, modelMin, paceTone, paceView, peakWindow, tempSymbol, }); const marketDecisionView = buildMarketDecisionView({ expectedHigh: decisionExpectedHighNumber, isEn, marketScan, marketStatus, tempSymbol, }); const localizedRisksRaw = (isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || []; const localizedRisks = Array.isArray(localizedRisksRaw) ? localizedRisksRaw : localizedRisksRaw ? [String(localizedRisksRaw)] : []; const aiBullets = [ localizedMetarRead, 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 || ""; const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`; return (
{isEn ? "Deep analysis" : "城市深度分析"}

{displayName}

{detail?.local_time || row?.local_time || "--"} DEB{" "} {debNumber != null ? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 }) : "--"} {isEn ? "Model" : "模型"} {modelRange} {isEn ? "Peak" : "峰值"} {peakWindow}
{isEn ? "Expected high" : "预计最高温"} {decisionExpectedHighNumber != null ? formatTemperatureValue(decisionExpectedHighNumber, tempSymbol, { digits: 1 }) : "--"}
{detail && !collapsed ? (
{decisionView.kicker} {decisionView.action}

{localizedFinalJudgment || paceText}

{decisionView.reasons.map((reason, index) => ( {reason} ))}

{decisionView.risk}

{isEn ? "Polymarket price layer" : "Polymarket 价格层"} {marketDecisionView.title}

{marketDecisionView.reason}

{isEn ? "Bucket" : "温度桶"} {marketDecisionView.bucketLabel} {isEn ? "YES buy" : "YES 买价"} {marketDecisionView.priceText} {isEn ? "Model-market" : "模型-市场差"} {marketDecisionView.edgeText}
{marketDecisionView.marketUrl ? ( {isEn ? "Open market" : "打开市场"} ) : null}
{isEn ? "Expected high" : "预计高点"} {decisionView.expectedHigh} {isEn ? "Weather range" : "天气区间"} {decisionView.targetRange} {isEn ? "Confidence" : "信心"} {decisionView.confidence} {isEn ? "Observed" : "实测"} {currentTempNumber != null ? formatTemperatureValue(currentTempNumber, tempSymbol, { digits: 1 }) : "--"} {isEn ? "Path delta" : "路径偏差"} {paceView?.deltaText || "--"} {isEn ? "Peak window" : "峰值窗口"} {peakWindow} {isEn ? "Market implied" : "市场隐含"} {marketDecisionView.impliedText} {isEn ? "Model prob" : "模型概率"} {marketDecisionView.modelText} {isEn ? "Quote status" : "报价状态"} {marketDecisionView.status === "ready" ? (isEn ? "Ready" : "已同步") : marketDecisionView.status === "loading" ? (isEn ? "Loading" : "同步中") : (isEn ? "Unavailable" : "不可用")}
{isHkoObservation ? isEn ? "Evidence · AI HKO observation read" : "证据 · AI 香港天文台观测解读" : isEn ? "Evidence · AI airport read" : "证据 · AI 机场报文解读"}
{aiForecast.status === "loading" ? ( <>

{localizedFinalJudgment || aiForecast.streamText || (isEn ? isHkoObservation ? "DeepSeek is reading the HKO observation and city context..." : "DeepSeek is reading the airport bulletin and city context..." : isHkoObservation ? "DeepSeek 正在统一解读香港天文台观测和城市上下文…" : "DeepSeek 正在统一解读机场报文和城市上下文…")}

{isEn ? isHkoObservation ? "One v4-flash stream now drives both the HKO observation read and city judgment." : "One v4-flash stream now drives both the airport read and city judgment." : isHkoObservation ? "现在由 v4-flash 一条流同时生成香港天文台观测解读和城市判断。" : "现在由 v4-flash 一条流同时生成机场报文解读和城市判断。"}

) : aiForecast.status === "ready" && aiCityForecast ? ( <>

{localizedFinalJudgment || (isEn ? "AI read returned without a final sentence." : "AI 已返回,但缺少最终判断。")}

    {aiBullets.map((line, index) => (
  • {line}
  • ))}

{rawObservationText}

) : aiForecast.status === "ready" ? ( <>

{aiForecast.payload?.status === "timeout" ? isEn ? "DeepSeek enhancement timed out. You can retry; city data and the right briefing were not refreshed." : "DeepSeek 增强本次超时,可稍后重试;城市数据和右侧简报不会被刷新。" : fallbackAiReason || (isEn ? "AI read is unavailable for this city right now." : "该城市暂时没有可用的 AI 解读。")}

  • {localModelSupportNote}
  • {rawObservationText}
) : aiForecast.status === "failed" ? ( <>

{isEn ? isHkoObservation ? "AI read failed. Model support and the HKO observation remain as fallback context." : "AI read failed. Model support and the raw METAR remain as fallback context." : isHkoObservation ? "AI 解读失败。下方保留多模型支撑和香港天文台观测作为兜底上下文。" : "AI 解读失败。下方保留多模型支撑和原始 METAR 作为兜底上下文。"} {aiForecast.error ? ` ${aiForecast.error}` : ""}

  • {localModelSupportNote}
  • {rawObservationText}
) : (

{isEn ? isHkoObservation ? "Waiting for AI to read the latest HKO observation." : "Waiting for AI to read the latest airport bulletin." : isHkoObservation ? "等待 AI 解读最新香港天文台观测。" : "等待 AI 解读最新机场报文。"}

)}
{isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑"}
) : !detail ? (
) : null}
); } export function AiPinnedForecastView({ items, rows, detailsByName, locale, onRefreshCityDetail, onRemoveCity, }: { items: AiPinnedCity[]; rows: ScanOpportunityRow[]; detailsByName: Record; locale: string; onRefreshCityDetail: (cityName: string) => Promise; onRemoveCity: (cityName: string) => void; }) { const isEn = locale === "en-US"; const [collapsedCities, setCollapsedCities] = useState>( () => new Set(), ); const [removingCities, setRemovingCities] = useState>( () => new Set(), ); const knownCityKeysRef = useRef>(new Set()); const removeTimersRef = useRef>>(new Map()); useEffect(() => { const activeKeys = new Set( items.map((item) => normalizeCityKey(item.cityName) || item.cityName), ); setCollapsedCities((current) => { const next = new Set(); 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 (
{isEn ? "Click a city on the map" : "从分布视图点击城市"}
{isEn ? "Selected cities will appear here as deep analysis blocks." : "被点击的城市会加入深度分析页,并保留为城市分析区块。"}
); } return (
{isEn ? "Selected city workspace" : "城市分析工作区"} {isEn ? `${items.length} cities under deep analysis` : `${items.length} 个城市正在深度分析`}

{isEn ? "Map clicks add cities here. City analysis stays here until you remove it." : "地图点击会把城市加入这里;城市分析会保留,直到你手动移除。"}

{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 ( removeCityWithMotion(item, stableKey)} onToggleCollapsed={() => { setCollapsedCities((current) => { const next = new Set(current); if (next.has(stableKey)) { next.delete(stableKey); } else { next.add(stableKey); } return next; }); }} /> ); })}
); }