738 lines
27 KiB
TypeScript
738 lines
27 KiB
TypeScript
"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 { 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";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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} 分钟前`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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" : "已同步";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 aiStatusLabel =
|
|||
|
|
aiForecast.status === "loading"
|
|||
|
|
? isEn
|
|||
|
|
? "Fast read ready"
|
|||
|
|
: "快速判断已完成"
|
|||
|
|
: aiForecast.status === "ready" && aiCityForecast
|
|||
|
|
? aiRuleEvidenceMode
|
|||
|
|
? isEn
|
|||
|
|
? "Rule evidence"
|
|||
|
|
: "规则证据模式"
|
|||
|
|
: isEn
|
|||
|
|
? "AI read complete"
|
|||
|
|
: "AI 解读已完成"
|
|||
|
|
: aiRuleEvidenceMode
|
|||
|
|
? isEn
|
|||
|
|
? "Rule evidence"
|
|||
|
|
: "规则证据模式"
|
|||
|
|
: isEn
|
|||
|
|
? "AI pending"
|
|||
|
|
: "AI 待返回";
|
|||
|
|
const aiStatusTone: StatusTagTone =
|
|||
|
|
aiForecast.status === "loading"
|
|||
|
|
? "blue"
|
|||
|
|
: aiForecast.status === "ready" && aiCityForecast
|
|||
|
|
? aiRuleEvidenceMode
|
|||
|
|
? "amber"
|
|||
|
|
: "green"
|
|||
|
|
: aiRuleEvidenceMode
|
|||
|
|
? "amber"
|
|||
|
|
: "muted";
|
|||
|
|
const marketStatusTone: StatusTagTone =
|
|||
|
|
marketDecisionView.status === "ready"
|
|||
|
|
? "green"
|
|||
|
|
: marketDecisionView.status === "loading"
|
|||
|
|
? "blue"
|
|||
|
|
: "muted";
|
|||
|
|
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 statusTags = uniqueStatusTags([
|
|||
|
|
observedHighBreak
|
|||
|
|
? {
|
|||
|
|
label: isEn ? "Observed breakout" : "实测突破",
|
|||
|
|
tone: "red",
|
|||
|
|
}
|
|||
|
|
: null,
|
|||
|
|
peakHasPassed
|
|||
|
|
? {
|
|||
|
|
label: isEn ? "Peak window passed" : "峰值窗口已过",
|
|||
|
|
tone: "muted",
|
|||
|
|
}
|
|||
|
|
: null,
|
|||
|
|
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"
|
|||
|
|
? {
|
|||
|
|
label: isEn ? "Fast read ready" : "快速判断已完成",
|
|||
|
|
tone: aiStatusTone,
|
|||
|
|
}
|
|||
|
|
: null,
|
|||
|
|
marketDecisionView.status === "unavailable"
|
|||
|
|
? {
|
|||
|
|
label: isEn ? "Market unavailable" : "市场价暂不可用",
|
|||
|
|
tone: marketStatusTone,
|
|||
|
|
}
|
|||
|
|
: null,
|
|||
|
|
modelHighlyConsistent
|
|||
|
|
? {
|
|||
|
|
label: isEn ? "Models agree" : "模型高度一致",
|
|||
|
|
tone: "green",
|
|||
|
|
}
|
|||
|
|
: null,
|
|||
|
|
observedLowLag || needsNextBulletin
|
|||
|
|
? {
|
|||
|
|
label: isEn ? "Wait next report" : "需要等待下一报文",
|
|||
|
|
tone: "amber",
|
|||
|
|
}
|
|||
|
|
: null,
|
|||
|
|
]).slice(0, 3);
|
|||
|
|
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 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"
|
|||
|
|
: "暂不可用";
|
|||
|
|
|
|||
|
|
const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<article className={clsx("scan-ai-city-card", collapsed && "collapsed", removing && "removing")}>
|
|||
|
|
<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}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
{detail && !collapsed ? (
|
|||
|
|
<div className="scan-ai-city-body" id={collapseId}>
|
|||
|
|
<WeatherDecisionBand
|
|||
|
|
currentTempText={currentTempText}
|
|||
|
|
decisionView={decisionView}
|
|||
|
|
decisionWhyText={decisionWhyText}
|
|||
|
|
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 today’s model stack, HKO observation context and market layer."
|
|||
|
|
: "Hydrating today’s model stack, METAR context and market layer."
|
|||
|
|
: isHkoObservation
|
|||
|
|
? "正在补全今日模型、香港天文台观测和市场价格层。"
|
|||
|
|
: "正在补全今日模型、机场报文和市场价格层。"
|
|||
|
|
}
|
|||
|
|
compact
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
</article>
|
|||
|
|
);
|
|||
|
|
}
|