feat: implement AI-driven city weather analysis and market decision dashboard components

This commit is contained in:
2569718930@qq.com
2026-04-26 10:02:54 +08:00
parent 0f23d8af9d
commit d54050ceb0
7 changed files with 348 additions and 62 deletions
@@ -11682,6 +11682,12 @@
height: 260px;
}
.root :global(.scan-ai-city-chart canvas) {
display: block;
width: 100% !important;
height: 100% !important;
}
.root :global(.scan-ai-city-chart-legend) {
display: flex;
gap: 14px;
@@ -1,17 +1,86 @@
import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { useMemo, useRef } from "react";
import { BarChart3 } from "lucide-react";
import type { CityDetail } from "@/lib/dashboard-types";
import { useChart } from "@/hooks/useChart";
import { useI18n } from "@/hooks/useI18n";
import { getTemperatureChartData } from "@/lib/dashboard-utils";
type TemperatureChartData = NonNullable<ReturnType<typeof getTemperatureChartData>>;
function compactSeries<T extends { time?: string | null; temp?: number | null }>(
rows?: T[] | null,
) {
return (Array.isArray(rows) ? rows : [])
.map((row) => `${String(row?.time || "").trim()}=${Number(row?.temp)}`)
.join("|");
}
function buildTemperatureChartSignature(detail: CityDetail) {
const hourly = detail.hourly || {};
const mgmHourly = Array.isArray(detail.mgm?.hourly) ? detail.mgm?.hourly || [] : [];
const tafMarkers = Array.isArray(detail.taf?.signal?.markers)
? detail.taf?.signal?.markers || []
: [];
return [
detail.name,
detail.local_date,
detail.temp_symbol,
(hourly.times || []).join("|"),
(hourly.temps || []).map((value) => Number(value)).join("|"),
detail.forecast?.today_high ?? "",
detail.deb?.prediction ?? "",
detail.mgm?.temp ?? "",
detail.mgm?.time ?? "",
mgmHourly
.map((row) => `${String(row?.time || "").trim()}=${Number(row?.temp)}`)
.join("|"),
compactSeries(detail.metar_today_obs),
compactSeries(detail.settlement_today_obs),
compactSeries(detail.trend?.recent),
detail.current?.temp ?? "",
detail.current?.obs_time ?? "",
detail.airport_current?.temp ?? "",
detail.airport_current?.obs_time ?? "",
detail.peak?.first_h ?? "",
detail.peak?.last_h ?? "",
tafMarkers
.map((marker) =>
[
marker?.marker_type,
marker?.label_time,
marker?.start_local,
marker?.end_local,
marker?.summary_zh,
marker?.summary_en,
]
.map((value) => String(value || "").trim())
.join("="),
)
.join("|"),
].join("::");
}
export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale } = useI18n();
const chartData = useMemo(
const cityKey = `${detail.name || detail.display_name || ""}:${detail.local_date || ""}`;
const chartSignature = useMemo(() => buildTemperatureChartSignature(detail), [detail]);
const computedChartData = useMemo(
() => getTemperatureChartData(detail, locale),
[detail, locale],
[chartSignature, locale],
);
const lastChartDataRef = useRef<{
cityKey: string;
data: TemperatureChartData;
} | null>(null);
if (computedChartData) {
lastChartDataRef.current = { cityKey, data: computedChartData };
}
const chartData =
computedChartData ||
(lastChartDataRef.current?.cityKey === cityKey
? lastChartDataRef.current.data
: null);
const forecastLabel = chartData?.datasets.hasMgmHourly
? locale === "en-US"
? "MGM forecast"
@@ -62,6 +131,7 @@ export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
labels: chartData.times,
},
options: {
animation: false,
interaction: { intersect: false, mode: "index" },
layout: { padding: { bottom: 2, left: 0, right: 8, top: 8 } },
maintainAspectRatio: false,
@@ -102,6 +172,12 @@ export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
},
},
},
transitions: {
active: { animation: { duration: 0 } },
hide: { animation: { duration: 0 } },
resize: { animation: { duration: 0 } },
show: { animation: { duration: 0 } },
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
@@ -17,6 +17,12 @@ import {
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 AiPinnedCityCard({
item,
detail,
@@ -86,6 +92,7 @@ function AiPinnedCityCard({
const { aiForecast, refreshAiForecast } = useAiCityForecast({
detail,
detailCityName,
enabled: Boolean(detail && !collapsed),
isEn,
locale,
report,
@@ -93,6 +100,7 @@ function AiPinnedCityCard({
const { marketScan, marketStatus } = useCityMarketScan({
detail,
detailCityName,
enabled: Boolean(detail && !collapsed),
});
const aiCityForecast = aiForecast.payload?.city_forecast || null;
@@ -125,8 +133,8 @@ function AiPinnedCityCard({
: isEn
? "Model support is unavailable, so this city must rely on DEB path and METAR observations."
: "暂无可用多模型支撑,需要主要参考 DEB 路径和 METAR 实测。";
const aiPredictedMax = Number(aiCityForecast?.predicted_max);
const decisionExpectedHighNumber = Number.isFinite(aiPredictedMax)
const aiPredictedMax = toFiniteDecisionNumber(aiCityForecast?.predicted_max);
const decisionExpectedHighNumber = aiPredictedMax != null
? aiPredictedMax
: paceView?.paceAdjustedHigh != null
? paceView.paceAdjustedHigh
@@ -5,6 +5,38 @@ import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import { formatTemperatureValue } from "@/lib/dashboard-utils";
import { formatShortDate, getPeakCountdownMeta } from "@/components/dashboard/scan-terminal/decision-utils";
function normalizeCalendarCityKey(value?: string | null) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, "");
}
function getCalendarCardKey(row: ScanOpportunityRow) {
const city =
normalizeCalendarCityKey(row.city) ||
normalizeCalendarCityKey(row.city_display_name) ||
normalizeCalendarCityKey(row.display_name);
const date = String(row.selected_date || row.local_date || "").trim();
return `${city || row.id}:${date || "date-unknown"}`;
}
function getCalendarRowScore(row: ScanOpportunityRow) {
return Number(row.final_score || 0) * 1000 + Number(row.edge_percent || 0);
}
function dedupeCalendarRows(rows: ScanOpportunityRow[]) {
const bestByCard = new Map<string, ScanOpportunityRow>();
rows.forEach((row) => {
const key = getCalendarCardKey(row);
const current = bestByCard.get(key);
if (!current || getCalendarRowScore(row) > getCalendarRowScore(current)) {
bestByCard.set(key, row);
}
});
return [...bestByCard.values()];
}
export function CalendarView({
rows,
locale,
@@ -26,7 +58,7 @@ export function CalendarView({
items: Array<{ row: ScanOpportunityRow; meta: ReturnType<typeof getPeakCountdownMeta> }>;
}
>();
rows.forEach((row) => {
dedupeCalendarRows(rows).forEach((row) => {
const meta = getPeakCountdownMeta(row, locale);
const current = byPhase.get(meta.key) || {
label: meta.groupLabel,
@@ -41,6 +41,12 @@ function normalizeQuotePrice(value: unknown) {
return normalized;
}
function toFiniteMarketNumber(value: unknown) {
if (value == null || value === "") return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
export function formatMarketPercent(value: number | null, digits = 1) {
if (value == null || !Number.isFinite(value)) return "--";
return `${(value * 100).toFixed(digits)}%`;
@@ -76,9 +82,8 @@ export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol
if (direct && /[°]?[CF]\b|\d+\s*[+-]?$/i.test(direct) && !/[。紊]/.test(direct)) {
return direct.replace(/\bC\b/g, "°C").replace(/\bF\b/g, "°F");
}
const value = bucket?.temp ?? bucket?.value ?? bucket?.lower ?? null;
const numeric = Number(value);
if (Number.isFinite(numeric)) {
const numeric = toFiniteMarketNumber(bucket?.temp ?? bucket?.value ?? bucket?.lower);
if (numeric != null) {
const unit = bucket?.unit
? `°${String(bucket.unit).replace(/^°/, "").toUpperCase()}`
: tempSymbol;
@@ -88,8 +93,7 @@ export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol
}
function getBucketAnchor(bucket: MarketTopBucket) {
const anchor = Number(bucket.temp ?? bucket.value ?? bucket.lower);
return Number.isFinite(anchor) ? anchor : null;
return toFiniteMarketNumber(bucket.temp ?? bucket.value ?? bucket.lower);
}
function getBucketModelProbability(bucket?: MarketTopBucket | null) {
@@ -157,7 +161,7 @@ export function pickMarketBucketForWeatherCenter(
return Math.abs(anchor - comparable) <= maxReasonableDelta;
};
if (!buckets.length || expectedHigh == null || !Number.isFinite(expectedHigh)) {
return selectedBucket;
return isReasonableFallback(selectedBucket) ? selectedBucket : null;
}
let nearest: MarketTopBucket | null = null;
@@ -364,22 +368,22 @@ export function buildWeatherDecisionView({
peakWindow: string;
tempSymbol: string;
}): WeatherDecisionView {
const aiPredicted = Number(aiCityForecast?.predicted_max);
const center = Number.isFinite(aiPredicted)
? aiPredicted
const aiPredictedMax = toFiniteMarketNumber(aiCityForecast?.predicted_max);
const center = aiPredictedMax != null
? aiPredictedMax
: paceView?.paceAdjustedHigh != null
? paceView.paceAdjustedHigh
: deb;
const aiLow = Number(aiCityForecast?.range_low);
const aiHigh = Number(aiCityForecast?.range_high);
const low = Number.isFinite(aiLow)
const aiLow = toFiniteMarketNumber(aiCityForecast?.range_low);
const aiHigh = toFiniteMarketNumber(aiCityForecast?.range_high);
const low = aiLow != null
? aiLow
: modelMin != null
? modelMin
: center != null
? center - 1
: null;
const high = Number.isFinite(aiHigh)
const high = aiHigh != null
? aiHigh
: modelMax != null
? modelMax
@@ -14,15 +14,115 @@ import { useDashboardStore } from "@/hooks/useDashboardStore";
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
import { normalizeCityKey } from "./decision-utils";
const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v1";
const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000;
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v1";
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000;
function getStorage() {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
function buildStorageKey(prefix: string, parts: Array<string | null | undefined>) {
return `${prefix}:${parts
.map((part) => encodeURIComponent(String(part || "").trim()))
.join(":")}`;
}
function readCachedPayload<T>(key: string, ttlMs: number): T | null {
const storage = getStorage();
if (!storage) return null;
try {
const raw = storage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as { cachedAt?: number; payload?: T };
if (!parsed?.payload) return null;
if (Date.now() - Number(parsed.cachedAt || 0) > ttlMs) {
storage.removeItem(key);
return null;
}
return parsed.payload;
} catch {
return null;
}
}
function writeCachedPayload<T>(key: string, payload: T) {
const storage = getStorage();
if (!storage) return;
try {
storage.setItem(key, JSON.stringify({ cachedAt: Date.now(), payload }));
} catch {
// Ignore quota/privacy-mode failures; network fallbacks still work.
}
}
function buildPartialAiStreamPayload({
fallbackText,
isEn,
tempSymbol,
}: {
fallbackText?: string | null;
isEn: boolean;
tempSymbol?: string | null;
}): AiCityForecastPayload {
const preservedText =
String(fallbackText || "").trim() ||
(isEn
? "The AI airport read stream was interrupted after partial output."
: "AI 机场报文解读已输出部分内容,但最终载荷未返回。");
const retryHint = isEn
? "The streaming connection ended before the final structured payload. The partial airport read above is preserved; refresh once if you need the full JSON-backed conclusion."
: "流式连接在最终结构化载荷返回前结束。上方已保留已输出的机场报文解读;如需完整 JSON 结论可刷新一次。";
return {
city_forecast: {
confidence: "low",
final_judgment_en: isEn
? preservedText
: "Partial AI airport read was preserved after the stream ended early.",
final_judgment_zh: isEn
? "AI 机场报文解读已保留部分输出,但流式连接提前结束。"
: preservedText,
metar_read_en: isEn ? preservedText : "",
metar_read_zh: isEn ? "" : preservedText,
model_cluster_note_en: "",
model_cluster_note_zh: "",
predicted_max: null,
range_high: null,
range_low: null,
reasoning_en: retryHint,
reasoning_zh: retryHint,
risks_en: isEn ? [retryHint] : [],
risks_zh: isEn ? [] : [retryHint],
unit: tempSymbol || "°C",
},
raw_reason: "partial_ai_stream_without_final_payload",
reason: retryHint,
reason_en: isEn
? retryHint
: "AI stream ended before the final payload; partial text was preserved.",
reason_zh: isEn ? "AI 流在最终载荷前结束;已保留部分文本。" : retryHint,
status: "partial_stream",
};
}
export function useAiCityForecast({
detail,
detailCityName,
isEn,
locale,
report,
enabled = true,
}: {
detail: CityDetail | null;
detailCityName: string;
enabled?: boolean;
isEn: boolean;
locale: string;
report: string;
@@ -34,18 +134,34 @@ export function useAiCityForecast({
const aiForecastKey = useMemo(
() =>
detail
? `${normalizeCityKey(detailCityName)}:${detail.local_date || ""}:${report || ""}`
? `${normalizeCityKey(detailCityName)}:${detail.local_date || ""}:${locale}:${report || ""}`
: "",
[detail, detailCityName, report],
[detail, detailCityName, locale, report],
);
const aiTempSymbol = detail?.temp_symbol || "°C";
useEffect(() => {
if (!aiForecastKey) {
if (!enabled || !aiForecastKey) {
setAiForecast({ status: "idle" });
return;
}
let cancelled = false;
const controller = new AbortController();
const cacheKey = buildStorageKey(AI_CITY_FORECAST_CACHE_PREFIX, [aiForecastKey]);
const cachedPayload =
aiRefreshToken <= 0
? readCachedPayload<AiCityForecastPayload>(
cacheKey,
AI_CITY_FORECAST_CACHE_TTL_MS,
)
: null;
if (cachedPayload) {
setAiForecast({ payload: cachedPayload, status: "ready" });
return () => {
cancelled = true;
controller.abort();
};
}
setAiForecast({ status: "loading", streamText: null, streamRaw: "" });
enqueueAiCityFetch(
() =>
@@ -196,43 +312,14 @@ export function useAiCityForecast({
if (!finalPayload) {
const fallbackText =
extractStreamingAirportRead(rawStream, locale) ||
latestReadableText ||
(isEn
? "The AI airport read stream was interrupted after partial output."
: "AI 机场报文解读已输出部分内容,但最终载荷未返回。");
const retryHint = isEn
? "The streaming connection ended before the final structured payload. The partial airport read above is preserved; refresh once if you need the full JSON-backed conclusion."
: "流式连接在最终结构化载荷返回前结束。上方已保留已输出的机场报文解读;如需完整 JSON 结论可刷新一次。";
return {
city_forecast: {
confidence: "low",
final_judgment_en: isEn
? fallbackText
: "Partial AI airport read was preserved after the stream ended early.",
final_judgment_zh: isEn
? "AI 机场报文解读已保留部分输出,但流式连接提前结束。"
: fallbackText,
metar_read_en: isEn ? fallbackText : "",
metar_read_zh: isEn ? "" : fallbackText,
model_cluster_note_en: "",
model_cluster_note_zh: "",
predicted_max: null,
range_high: null,
range_low: null,
reasoning_en: retryHint,
reasoning_zh: retryHint,
risks_en: isEn ? [retryHint] : [],
risks_zh: isEn ? [] : [retryHint],
unit: detail?.temp_symbol || "°C",
},
raw_reason: "AI stream ended before final payload",
reason: retryHint,
reason_en: isEn
? retryHint
: "AI stream ended before the final payload; partial text was preserved.",
reason_zh: isEn ? "AI 流在最终载荷前结束;已保留部分文本。" : retryHint,
status: "partial_stream",
};
latestReadableText;
const partialPayload = buildPartialAiStreamPayload({
fallbackText,
isEn,
tempSymbol: aiTempSymbol,
});
writeCachedPayload(cacheKey, partialPayload);
return partialPayload;
}
return finalPayload;
}),
@@ -270,12 +357,29 @@ export function useAiCityForecast({
)
.then((payload) => {
if (!cancelled) {
writeCachedPayload(cacheKey, payload);
setAiForecast({ payload, status: "ready" });
}
})
.catch((error) => {
if (controller.signal.aborted) return;
if (!cancelled) {
const message = String(error);
if (message.includes("AI stream ended before final payload")) {
setAiForecast((current) => {
const partialPayload = buildPartialAiStreamPayload({
fallbackText: current.streamText,
isEn,
tempSymbol: aiTempSymbol,
});
writeCachedPayload(cacheKey, partialPayload);
return {
payload: partialPayload,
status: "ready",
};
});
return;
}
setAiForecast({ error: String(error), status: "failed" });
}
});
@@ -283,7 +387,7 @@ export function useAiCityForecast({
cancelled = true;
controller.abort();
};
}, [aiForecastKey, aiRefreshToken, detailCityName, isEn, locale]);
}, [aiForecastKey, aiRefreshToken, aiTempSymbol, detailCityName, enabled, isEn, locale]);
const refreshAiForecast = useCallback(() => {
setAiRefreshToken((current) => current + 1);
@@ -295,9 +399,11 @@ export function useAiCityForecast({
export function useCityMarketScan({
detail,
detailCityName,
enabled = true,
}: {
detail: CityDetail | null;
detailCityName: string;
enabled?: boolean;
}) {
const ensureCityMarketScan = useDashboardStore().ensureCityMarketScan;
const [marketScan, setMarketScan] = useState<MarketScan | null>(
@@ -313,10 +419,46 @@ export function useCityMarketScan({
setMarketStatus("idle");
return;
}
const cacheKey = buildStorageKey(CITY_MARKET_SCAN_CACHE_PREFIX, [
normalizeCityKey(detailCityName),
detail.local_date || "",
"lite",
]);
let cancelled = false;
if (detail.market_scan) {
setMarketScan(detail.market_scan);
setMarketStatus("ready");
writeCachedPayload(cacheKey, detail.market_scan);
return () => {
cancelled = true;
};
}
if (!enabled) {
const cached = readCachedPayload<MarketScan>(
cacheKey,
CITY_MARKET_SCAN_CACHE_TTL_MS,
);
if (cached) {
setMarketScan(cached);
setMarketStatus("ready");
} else {
setMarketScan(null);
setMarketStatus("idle");
}
return () => {
cancelled = true;
};
}
const cached = readCachedPayload<MarketScan>(
cacheKey,
CITY_MARKET_SCAN_CACHE_TTL_MS,
);
if (cached) {
setMarketScan(cached);
setMarketStatus("ready");
return () => {
cancelled = true;
};
} else {
setMarketStatus("loading");
}
@@ -326,6 +468,9 @@ export function useCityMarketScan({
})
.then((payload) => {
if (cancelled) return;
if (payload) {
writeCachedPayload(cacheKey, payload);
}
setMarketScan(payload || detail.market_scan || null);
setMarketStatus("ready");
})
@@ -337,7 +482,7 @@ export function useCityMarketScan({
return () => {
cancelled = true;
};
}, [detail, detailCityName, ensureCityMarketScan]);
}, [detail, detailCityName, enabled, ensureCityMarketScan]);
return { marketScan, marketStatus };
}
+16 -1
View File
@@ -2872,16 +2872,31 @@ def _build_city_market_scan_payload(
primary_bucket = None
if isinstance(distribution, list) and distribution:
ranked_buckets = []
temp_symbol_upper = str(temp_symbol or "").upper()
max_primary_bucket_delta = 16.0 if "F" in temp_symbol_upper else 8.0
for idx, row in enumerate(distribution_all):
if not isinstance(row, dict):
continue
bucket_value = _sf(
row.get("temp")
if row.get("temp") is not None
else row.get("value")
if row.get("value") is not None
else row.get("lower")
)
if (
anchor_temp is not None
and bucket_value is not None
and abs(float(bucket_value) - float(anchor_temp)) > max_primary_bucket_delta
):
continue
bucket_prob = _sf(row.get("probability"))
prob_rank = bucket_prob if bucket_prob is not None else -1.0
ranked_buckets.append((-prob_rank, idx, row))
if ranked_buckets:
ranked_buckets.sort(key=lambda x: (x[0], x[1]))
primary_bucket = ranked_buckets[0][2]
else:
elif anchor_temp is None:
primary_bucket = distribution[0]
model_probability = None