城市图表增加 LoadingSignal 组件:detail 数据加载中显示进度指示
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
||||||
import {
|
import {
|
||||||
Brush,
|
Brush,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
@@ -199,6 +200,47 @@ const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
|
|||||||
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
|
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
|
||||||
const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
|
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 {
|
function validNumber(value: unknown): number | null {
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
}
|
}
|
||||||
@@ -419,11 +461,16 @@ async function fetchHourlyForecastForCity(city: string): Promise<HourlyForecast>
|
|||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionCached = readSessionCache(city);
|
||||||
|
if (sessionCached) {
|
||||||
|
_hourlyCache.set(city, sessionCached);
|
||||||
|
return sessionCached.data;
|
||||||
|
}
|
||||||
|
|
||||||
const pending = _hourlyRequestCache.get(city);
|
const pending = _hourlyRequestCache.get(city);
|
||||||
if (pending) return pending;
|
if (pending) return pending;
|
||||||
|
|
||||||
const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`, {
|
const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`, {
|
||||||
cache: "no-store",
|
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.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,
|
airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined,
|
||||||
};
|
};
|
||||||
_hourlyCache.set(city, { ts: Date.now(), data });
|
_hourlyCache.set(city, { ts: Date.now(), data });
|
||||||
|
writeSessionCache(city, data);
|
||||||
return data;
|
return data;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -1049,6 +1097,8 @@ export function LiveTemperatureThresholdChart({
|
|||||||
onClose,
|
onClose,
|
||||||
isMaximized = false,
|
isMaximized = false,
|
||||||
disableClose = false,
|
disableClose = false,
|
||||||
|
isActive = !compact,
|
||||||
|
slotIndex = 0,
|
||||||
}: {
|
}: {
|
||||||
isEn: boolean;
|
isEn: boolean;
|
||||||
row: ScanOpportunityRow | null;
|
row: ScanOpportunityRow | null;
|
||||||
@@ -1059,8 +1109,11 @@ export function LiveTemperatureThresholdChart({
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
isMaximized?: boolean;
|
isMaximized?: boolean;
|
||||||
disableClose?: boolean;
|
disableClose?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
slotIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
const [hourly, setHourly] = useState<HourlyForecast>(null);
|
const [hourly, setHourly] = useState<HourlyForecast>(null);
|
||||||
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
const city = String(row?.city || "").toLowerCase().trim();
|
const city = String(row?.city || "").toLowerCase().trim();
|
||||||
const [timeframe, setTimeframe] = useState<"1D" | "3D">("1D");
|
const [timeframe, setTimeframe] = useState<"1D" | "3D">("1D");
|
||||||
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
|
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
|
||||||
@@ -1071,21 +1124,48 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!city) return;
|
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) {
|
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
|
||||||
setHourly(cached.data);
|
setHourly(cached.data);
|
||||||
|
setIsFetching(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setHourly(seedHourlyForecastFromRow(row));
|
setHourly(seedHourlyForecastFromRow(row));
|
||||||
|
setIsFetching(true);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchHourlyForecastForCity(city)
|
|
||||||
.then((data) => {
|
// Prioritize active slots, stagger/delay background slots to optimize load performance
|
||||||
if (cancelled || !data) return;
|
const delay = isActive ? 0 : (slotIndex ? 300 + slotIndex * 250 : 350);
|
||||||
setHourly(data);
|
|
||||||
})
|
const timer = setTimeout(() => {
|
||||||
.catch(() => {});
|
fetchHourlyForecastForCity(city)
|
||||||
return () => { cancelled = true; };
|
.then((data) => {
|
||||||
}, [city, row]);
|
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(() => {
|
const { data, series } = useMemo(() => {
|
||||||
if (timeframe === "3D") {
|
if (timeframe === "3D") {
|
||||||
@@ -1125,38 +1205,42 @@ export function LiveTemperatureThresholdChart({
|
|||||||
return getVisibleTemperatureSeries(city, chartSeries, userToggledKeys);
|
return getVisibleTemperatureSeries(city, chartSeries, userToggledKeys);
|
||||||
}, [chartSeries, userToggledKeys, city]);
|
}, [chartSeries, userToggledKeys, city]);
|
||||||
|
|
||||||
const cityKey = String(row?.city || "").toLowerCase().trim();
|
const normalizedKey = normalizeCityKey(row?.city);
|
||||||
const runwaySensorCities = new Set([
|
const runwaySensorCities = new Set([
|
||||||
'beijing', 'shanghai', 'guangzhou', 'qingdao',
|
'beijing', 'shanghai', 'guangzhou', 'qingdao',
|
||||||
'chengdu', 'chongqing', 'wuhan', // AMSC runway sensors
|
'chengdu', 'chongqing', 'wuhan', // AMSC runway sensors
|
||||||
'seoul', 'busan', // AMOS runway sensors
|
'seoul', 'busan', // AMOS runway sensors
|
||||||
]);
|
]);
|
||||||
const isHKO = cityKey === 'hong kong' || cityKey === 'lau fau shan' || cityKey === 'shenzhen'
|
const isShenzhen = normalizedKey === 'shenzhen';
|
||||||
|| cityKey.includes('hongkong') || cityKey.includes('laufau');
|
const isHKO = (normalizedKey === 'hongkong' || normalizedKey === 'laufaushan') && !isShenzhen;
|
||||||
const isTokyo = cityKey === 'tokyo';
|
const isTokyo = normalizedKey === 'tokyo';
|
||||||
const isSingapore = cityKey === 'singapore';
|
const isSingapore = normalizedKey === 'singapore';
|
||||||
const isParis = cityKey === 'paris';
|
const isParis = normalizedKey === 'paris';
|
||||||
const isWeatherStation = !runwaySensorCities.has(cityKey)
|
const isWeatherStation = !runwaySensorCities.has(normalizedKey)
|
||||||
&& !isHKO && !isTokyo && !isSingapore && !isParis;
|
&& !isHKO && !isShenzhen && !isTokyo && !isSingapore && !isParis;
|
||||||
|
|
||||||
const runwayHeaderLabel = isHKO ? '参考站点 (1分钟)'
|
const runwayHeaderLabel = isShenzhen ? '天文台实测 (10分钟)'
|
||||||
|
: isHKO ? '参考站点 (1分钟)'
|
||||||
: isTokyo ? '机场气象站 (10分钟)'
|
: isTokyo ? '机场气象站 (10分钟)'
|
||||||
: isSingapore ? '航站楼温度'
|
: isSingapore ? '航站楼温度'
|
||||||
: isParis ? '官方机场观测 (15分钟)'
|
: isParis ? '官方机场观测 (15分钟)'
|
||||||
: isWeatherStation ? '气象站实测'
|
: isWeatherStation ? '气象站实测'
|
||||||
: '跑道实测 (1分钟)';
|
: '跑道实测 (1分钟)';
|
||||||
|
|
||||||
const metarHeaderLabel = isHKO ? '天文台实测 (10分钟)'
|
const metarHeaderLabel = isShenzhen ? '天文台实测 (10分钟)'
|
||||||
|
: isHKO ? '天文台实测 (10分钟)'
|
||||||
: 'METAR 结算 (30分钟)';
|
: 'METAR 结算 (30分钟)';
|
||||||
|
|
||||||
const runwayHighLabel = isHKO ? '参考站点'
|
const runwayHighLabel = isShenzhen ? '天文台实测'
|
||||||
|
: isHKO ? '参考站点'
|
||||||
: isTokyo ? '机场气象站'
|
: isTokyo ? '机场气象站'
|
||||||
: isSingapore ? '航站楼'
|
: isSingapore ? '航站楼'
|
||||||
: isParis ? '官方机场观测'
|
: isParis ? '官方机场观测'
|
||||||
: isWeatherStation ? '气象站'
|
: isWeatherStation ? '气象站'
|
||||||
: '跑道实测';
|
: '跑道实测';
|
||||||
|
|
||||||
const metarHighLabel = isHKO ? '天文台'
|
const metarHighLabel = isShenzhen ? '天文台'
|
||||||
|
: isHKO ? '天文台'
|
||||||
: 'METAR 官方';
|
: 'METAR 官方';
|
||||||
|
|
||||||
const { currentRunwayTemp, observedHighMetar, observedHighRunway } = useMemo(
|
const { currentRunwayTemp, observedHighMetar, observedHighRunway } = useMemo(
|
||||||
@@ -1313,6 +1397,13 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel title={panelTitle} actions={timeframeActions}>
|
<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">
|
<div className="flex h-full min-h-[300px] flex-col">
|
||||||
{/* Compact stats bar */}
|
{/* Compact stats bar */}
|
||||||
{compact ? (
|
{compact ? (
|
||||||
@@ -1325,7 +1416,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-slate-300">|</span>
|
<span className="text-slate-300">|</span>
|
||||||
<span className="font-semibold text-slate-500">
|
<span className="font-semibold text-slate-500">
|
||||||
{isEn ? "METAR" : metarHeaderLabel}:{" "}
|
{isEn ? "METAR" : (isShenzhen ? "当日最高" : metarHeaderLabel)}:{" "}
|
||||||
<strong className="text-blue-600 font-mono">{temp(observedHighMetar)}</strong>
|
<strong className="text-blue-600 font-mono">{temp(observedHighMetar)}</strong>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user