城市图表增加 LoadingSignal 组件:detail 数据加载中显示进度指示

This commit is contained in:
2569718930@qq.com
2026-05-26 07:57:38 +08:00
parent 20d53e1ae2
commit 598cd17c0b
@@ -2,6 +2,7 @@
import clsx from "clsx";
import { useEffect, useMemo, useState } from "react";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import {
Brush,
CartesianGrid,
@@ -199,6 +200,47 @@ const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:";
const SESSION_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes cache for session storage
function readSessionCache(city: string): { ts: number; data: HourlyForecast } | null {
if (typeof window === "undefined") return null;
try {
const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`);
if (!raw) return null;
const item = JSON.parse(raw);
if (item && item.ts && Date.now() - item.ts < SESSION_CACHE_TTL_MS) {
return item;
}
} catch {}
return null;
}
function writeSessionCache(city: string, data: HourlyForecast) {
if (typeof window === "undefined" || !data) return;
try {
sessionStorage.setItem(
`${SESSION_CACHE_PREFIX}${city}`,
JSON.stringify({ ts: Date.now(), data })
);
} catch {}
}
export function clearCityDetailCache() {
_hourlyCache.clear();
_hourlyRequestCache.clear();
if (typeof window !== "undefined") {
try {
for (let i = sessionStorage.length - 1; i >= 0; i--) {
const key = sessionStorage.key(i);
if (key && key.startsWith(SESSION_CACHE_PREFIX)) {
sessionStorage.removeItem(key);
}
}
} catch {}
}
}
function validNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
@@ -419,11 +461,16 @@ async function fetchHourlyForecastForCity(city: string): Promise<HourlyForecast>
return cached.data;
}
const sessionCached = readSessionCache(city);
if (sessionCached) {
_hourlyCache.set(city, sessionCached);
return sessionCached.data;
}
const pending = _hourlyRequestCache.get(city);
if (pending) return pending;
const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`, {
cache: "no-store",
headers: { Accept: "application/json" },
})
.then(async (res) => {
@@ -451,6 +498,7 @@ async function fetchHourlyForecastForCity(city: string): Promise<HourlyForecast>
airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined,
};
_hourlyCache.set(city, { ts: Date.now(), data });
writeSessionCache(city, data);
return data;
})
.finally(() => {
@@ -1049,6 +1097,8 @@ export function LiveTemperatureThresholdChart({
onClose,
isMaximized = false,
disableClose = false,
isActive = !compact,
slotIndex = 0,
}: {
isEn: boolean;
row: ScanOpportunityRow | null;
@@ -1059,8 +1109,11 @@ export function LiveTemperatureThresholdChart({
onClose?: () => void;
isMaximized?: boolean;
disableClose?: boolean;
isActive?: boolean;
slotIndex?: number;
}) {
const [hourly, setHourly] = useState<HourlyForecast>(null);
const [isFetching, setIsFetching] = useState(false);
const city = String(row?.city || "").toLowerCase().trim();
const [timeframe, setTimeframe] = useState<"1D" | "3D">("1D");
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
@@ -1071,21 +1124,48 @@ export function LiveTemperatureThresholdChart({
useEffect(() => {
if (!city) return;
const cached = _hourlyCache.get(city);
// Check in-memory cache first
let cached = _hourlyCache.get(city);
if (!cached) {
// Fallback to session cache
const sessionEntry = readSessionCache(city);
if (sessionEntry) {
cached = sessionEntry;
_hourlyCache.set(city, sessionEntry);
}
}
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
setHourly(cached.data);
setIsFetching(false);
return;
}
setHourly(seedHourlyForecastFromRow(row));
setIsFetching(true);
let cancelled = false;
fetchHourlyForecastForCity(city)
.then((data) => {
if (cancelled || !data) return;
setHourly(data);
})
.catch(() => {});
return () => { cancelled = true; };
}, [city, row]);
// Prioritize active slots, stagger/delay background slots to optimize load performance
const delay = isActive ? 0 : (slotIndex ? 300 + slotIndex * 250 : 350);
const timer = setTimeout(() => {
fetchHourlyForecastForCity(city)
.then((data) => {
if (cancelled || !data) return;
setHourly(data);
setIsFetching(false);
})
.catch(() => {
if (!cancelled) setIsFetching(false);
});
}, delay);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [city, row, isActive, slotIndex]);
const { data, series } = useMemo(() => {
if (timeframe === "3D") {
@@ -1125,38 +1205,42 @@ export function LiveTemperatureThresholdChart({
return getVisibleTemperatureSeries(city, chartSeries, userToggledKeys);
}, [chartSeries, userToggledKeys, city]);
const cityKey = String(row?.city || "").toLowerCase().trim();
const normalizedKey = normalizeCityKey(row?.city);
const runwaySensorCities = new Set([
'beijing', 'shanghai', 'guangzhou', 'qingdao',
'chengdu', 'chongqing', 'wuhan', // AMSC runway sensors
'seoul', 'busan', // AMOS runway sensors
]);
const isHKO = cityKey === 'hong kong' || cityKey === 'lau fau shan' || cityKey === 'shenzhen'
|| cityKey.includes('hongkong') || cityKey.includes('laufau');
const isTokyo = cityKey === 'tokyo';
const isSingapore = cityKey === 'singapore';
const isParis = cityKey === 'paris';
const isWeatherStation = !runwaySensorCities.has(cityKey)
&& !isHKO && !isTokyo && !isSingapore && !isParis;
const isShenzhen = normalizedKey === 'shenzhen';
const isHKO = (normalizedKey === 'hongkong' || normalizedKey === 'laufaushan') && !isShenzhen;
const isTokyo = normalizedKey === 'tokyo';
const isSingapore = normalizedKey === 'singapore';
const isParis = normalizedKey === 'paris';
const isWeatherStation = !runwaySensorCities.has(normalizedKey)
&& !isHKO && !isShenzhen && !isTokyo && !isSingapore && !isParis;
const runwayHeaderLabel = isHKO ? '参考站点 (1分钟)'
const runwayHeaderLabel = isShenzhen ? '天文台实测 (10分钟)'
: isHKO ? '参考站点 (1分钟)'
: isTokyo ? '机场气象站 (10分钟)'
: isSingapore ? '航站楼温度'
: isParis ? '官方机场观测 (15分钟)'
: isWeatherStation ? '气象站实测'
: '跑道实测 (1分钟)';
const metarHeaderLabel = isHKO ? '天文台实测 (10分钟)'
const metarHeaderLabel = isShenzhen ? '天文台实测 (10分钟)'
: isHKO ? '天文台实测 (10分钟)'
: 'METAR 结算 (30分钟)';
const runwayHighLabel = isHKO ? '参考站点'
const runwayHighLabel = isShenzhen ? '天文台实测'
: isHKO ? '参考站点'
: isTokyo ? '机场气象站'
: isSingapore ? '航站楼'
: isParis ? '官方机场观测'
: isWeatherStation ? '气象站'
: '跑道实测';
const metarHighLabel = isHKO ? '天文台'
const metarHighLabel = isShenzhen ? '天文台'
: isHKO ? '天文台'
: 'METAR 官方';
const { currentRunwayTemp, observedHighMetar, observedHighRunway } = useMemo(
@@ -1313,6 +1397,13 @@ export function LiveTemperatureThresholdChart({
return (
<Panel title={panelTitle} actions={timeframeActions}>
{isFetching && (
<LoadingSignal
title={isEn ? "Loading city data..." : "加载城市数据中..."}
description={isEn ? `Fetching latest observations for ${row?.city_display_name || row?.city || "this city"}` : `正在获取 ${row?.city_display_name || row?.city || "该城市"} 的最新观测数据`}
compact={compact}
/>
)}
<div className="flex h-full min-h-[300px] flex-col">
{/* Compact stats bar */}
{compact ? (
@@ -1325,7 +1416,7 @@ export function LiveTemperatureThresholdChart({
</span>
<span className="text-slate-300">|</span>
<span className="font-semibold text-slate-500">
{isEn ? "METAR" : metarHeaderLabel}:{" "}
{isEn ? "METAR" : (isShenzhen ? "当日最高" : metarHeaderLabel)}:{" "}
<strong className="text-blue-600 font-mono">{temp(observedHighMetar)}</strong>
</span>
</div>