Files
PolyWeather/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx
T

626 lines
23 KiB
TypeScript
Raw Normal View History

"use client";
import clsx from "clsx";
import { useEffect, useState } from "react";
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
import { CityCardHeader } from "@/components/dashboard/scan-terminal/CityCardHeader";
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
import { WeatherDecisionBand } from "@/components/dashboard/scan-terminal/WeatherDecisionBand";
import {
buildMarketDecisionView,
buildWeatherDecisionView,
resolveExpectedHighCandidate,
} from "@/components/dashboard/scan-terminal/city-card-decision-utils";
import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state";
import { 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 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} 分钟前`;
}
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({
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",
);
if (stale) {
return isEn ? "stale; background only" : "已过旧,仅作背景参考";
}
const ageLabel = formatFreshnessAge(
isHkoObservation ? detail?.current?.obs_age_min : detail?.airport_current?.obs_age_min ?? detail?.current?.obs_age_min,
isEn,
);
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" : "同步中";
if (!marketScan?.available) return isEn ? "temporarily unavailable" : "暂不可用";
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" : "已同步";
}
export 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<void>;
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 [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);
}, []);
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 expectedHighText =
decisionExpectedHighNumber != null
? formatTemperatureValue(decisionExpectedHighNumber, tempSymbol, { digits: 1 })
: "--";
const currentTempText =
currentTempNumber != null
? formatTemperatureValue(currentTempNumber, tempSymbol, { digits: 1 })
: "--";
const debText =
debNumber != null
? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 })
: "--";
const marketDecisionView = buildMarketDecisionView({
expectedHigh: decisionExpectedHighNumber,
isEn,
marketScan,
marketStatus,
tempSymbol,
});
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(),
),
);
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");
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 解读未完整返回,当前使用规则证据";
const decisionState = buildCityDecisionState({
aiCityForecast,
aiForecast,
aiRuleEvidenceMode,
isEn,
isHkoObservation,
marketDecisionView,
modelHighlyConsistent,
needsNextBulletin,
observationStale,
observedHighBreak,
observedLowBreak,
observedLowLag,
peakHasPassed,
});
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 ? ": " : "";
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 marketLineText =
marketDecisionView.status === "ready"
? `${marketDecisionView.bucketLabel} · ${marketDecisionView.priceText}`
: marketDecisionView.status === "loading"
? isEn
? "syncing"
: "同步中"
: isEn
? "temporarily unavailable"
: "暂不可用";
const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`;
return (
<article
className={clsx("scan-ai-city-card", collapsed && "collapsed", removing && "removing")}
data-ai-status={decisionState.aiStatus}
data-evidence-quality={decisionState.evidenceQuality}
data-market-status={decisionState.marketStatus}
data-recommendation={decisionState.recommendation}
data-urgency={decisionState.urgency}
>
<CityCardHeader
aiStatusLabel={decisionState.aiStatusLabel}
aiStatusTone={decisionState.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={decisionState.badges}
/>
{detail && !collapsed ? (
<div className="scan-ai-city-body" id={collapseId}>
<WeatherDecisionBand
currentTempText={currentTempText}
decisionView={decisionView}
decisionWhyText={decisionState.primaryReason}
isEn={isEn}
longText={localizedFinalJudgment || paceText}
marketDecisionView={marketDecisionView}
marketLineText={marketLineText}
paceDeltaText={paceView?.deltaText || "--"}
peakWindow={peakWindow}
/>
<div className="scan-ai-city-analysis-grid">
<AiCityTemperatureChart detail={detail} />
<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}
/>
</div>
<ModelEvidencePanel detail={detail} isEn={isEn} />
</div>
) : !detail ? (
<div className="scan-ai-city-loading">
<LoadingSignal
title={isEn ? "Loading city decision data" : "正在加载城市决策数据"}
description={
isEn
? isHkoObservation
? "Hydrating todays model stack, HKO observation context and market layer."
: "Hydrating todays model stack, METAR context and market layer."
: isHkoObservation
? "正在补全今日模型、香港天文台观测和市场价格层。"
: "正在补全今日模型、机场报文和市场价格层。"
}
compact
/>
</div>
) : null}
</article>
);
}