feat: implement AI-driven city weather analysis and market decision dashboard components
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user