diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 89f2345f..532d6681 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -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; diff --git a/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx b/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx index 4de4c6f5..4d1b9c09 100644 --- a/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx +++ b/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx @@ -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>; + +function compactSeries( + 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">; diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx index be9e980f..d5c694ac 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx @@ -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 diff --git a/frontend/components/dashboard/scan-terminal/CalendarView.tsx b/frontend/components/dashboard/scan-terminal/CalendarView.tsx index 3d4b356b..6f1ea328 100644 --- a/frontend/components/dashboard/scan-terminal/CalendarView.tsx +++ b/frontend/components/dashboard/scan-terminal/CalendarView.tsx @@ -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(); + 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 }>; } >(); - rows.forEach((row) => { + dedupeCalendarRows(rows).forEach((row) => { const meta = getPeakCountdownMeta(row, locale); const current = byPhase.get(meta.key) || { label: meta.groupLabel, diff --git a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts index e6905ad6..03bb287f 100644 --- a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts +++ b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts @@ -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 diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts index 50a9d03c..31d7d538 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts @@ -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) { + return `${prefix}:${parts + .map((part) => encodeURIComponent(String(part || "").trim())) + .join(":")}`; +} + +function readCachedPayload(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(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( + 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( @@ -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( + cacheKey, + CITY_MARKET_SCAN_CACHE_TTL_MS, + ); + if (cached) { + setMarketScan(cached); + setMarketStatus("ready"); + } else { + setMarketScan(null); + setMarketStatus("idle"); + } + return () => { + cancelled = true; + }; + } + const cached = readCachedPayload( + 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 }; } diff --git a/web/analysis_service.py b/web/analysis_service.py index 41e7f9c0..4b9d12ef 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -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