feat: implement multi-source weather data collection system and dashboard frontend with integrated analysis services.
This commit is contained in:
@@ -5,7 +5,7 @@ import clsx from "clsx";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { preloadChartJs, useChart } from "@/hooks/useChart";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
|
||||
@@ -360,6 +360,12 @@ export function DetailPanel() {
|
||||
: `${t("detail.todayAnalysis")} (Pro)`
|
||||
}
|
||||
onClick={() => handleFeatureAccess("today")}
|
||||
onFocus={() => {
|
||||
void preloadChartJs();
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
void preloadChartJs();
|
||||
}}
|
||||
disabled={!store.selectedCity}
|
||||
>
|
||||
{isPro
|
||||
@@ -373,6 +379,12 @@ export function DetailPanel() {
|
||||
isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
|
||||
}
|
||||
onClick={() => handleFeatureAccess("history")}
|
||||
onFocus={() => {
|
||||
void preloadChartJs();
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
void preloadChartJs();
|
||||
}}
|
||||
disabled={!store.selectedCity}
|
||||
>
|
||||
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
|
||||
import type { ChartConfiguration } from "chart.js";
|
||||
import clsx from "clsx";
|
||||
import { CSSProperties, useMemo } from "react";
|
||||
import { CSSProperties, useEffect, useMemo, useState } from "react";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
@@ -626,13 +626,47 @@ export function FutureForecastModal() {
|
||||
const dateStr = store.futureModalDate;
|
||||
const isPro = store.proAccess.subscriptionActive;
|
||||
const isProLoading = store.proAccess.loading;
|
||||
const [showDeferredTodaySections, setShowDeferredTodaySections] = useState(false);
|
||||
|
||||
if (!detail || !dateStr) return null;
|
||||
|
||||
useEffect(() => {
|
||||
setShowDeferredTodaySections(false);
|
||||
if (typeof window === "undefined") {
|
||||
setShowDeferredTodaySections(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let idleId: number | null = null;
|
||||
const reveal = () => {
|
||||
if (!cancelled) {
|
||||
setShowDeferredTodaySections(true);
|
||||
}
|
||||
};
|
||||
|
||||
if ("requestIdleCallback" in window) {
|
||||
idleId = window.requestIdleCallback(reveal, { timeout: 600 });
|
||||
} else {
|
||||
timeoutId = setTimeout(reveal, 120);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (idleId != null && "cancelIdleCallback" in window) {
|
||||
window.cancelIdleCallback(idleId);
|
||||
}
|
||||
if (timeoutId != null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [dateStr, detail]);
|
||||
|
||||
const isToday = dateStr === detail.local_date;
|
||||
const detailDepth = detail.detail_depth || "full";
|
||||
const isFullDetailReady = detailDepth === "full";
|
||||
const isStructureSyncing = store.loadingState.refresh || !isFullDetailReady;
|
||||
const isStructureSyncing = store.loadingState.futureDeep || !isFullDetailReady;
|
||||
const isMarketSyncing = store.loadingState.marketScan;
|
||||
const isAnyLayerSyncing = isStructureSyncing || isMarketSyncing;
|
||||
const view = getFutureModalView(detail, dateStr, locale);
|
||||
@@ -642,8 +676,11 @@ export function FutureForecastModal() {
|
||||
} as CSSProperties & { "--score-position": string };
|
||||
const weatherSummary = getWeatherSummary(detail, locale);
|
||||
const paceView = useMemo(
|
||||
() => (isToday ? getTodayPaceView(detail, locale) : null),
|
||||
[detail, isToday, locale],
|
||||
() =>
|
||||
isToday && showDeferredTodaySections
|
||||
? getTodayPaceView(detail, locale)
|
||||
: null,
|
||||
[detail, isToday, locale, showDeferredTodaySections],
|
||||
);
|
||||
const probabilityView = useMemo(
|
||||
() => getProbabilityView(detail, dateStr),
|
||||
@@ -674,6 +711,7 @@ export function FutureForecastModal() {
|
||||
};
|
||||
}, [modelView]);
|
||||
const boundaryRiskView = useMemo(() => {
|
||||
if (!showDeferredTodaySections) return null;
|
||||
if (!isToday || !paceView) return null;
|
||||
const selectedBucket = marketScan?.temperature_bucket || null;
|
||||
const bounds = parseBucketBoundaries(selectedBucket);
|
||||
@@ -718,8 +756,9 @@ export function FutureForecastModal() {
|
||||
tone,
|
||||
value: `${nearest.gap.toFixed(1)}${detail.temp_symbol}`,
|
||||
};
|
||||
}, [detail.deb?.prediction, detail.temp_symbol, isToday, locale, marketScan?.temperature_bucket, paceView]);
|
||||
}, [detail.deb?.prediction, detail.temp_symbol, isToday, locale, marketScan?.temperature_bucket, paceView, showDeferredTodaySections]);
|
||||
const peakWindowStateView = useMemo(() => {
|
||||
if (!showDeferredTodaySections) return null;
|
||||
if (!isToday || !paceView) return null;
|
||||
const firstHour = Number(detail.peak?.first_h);
|
||||
const lastHour = Number(detail.peak?.last_h);
|
||||
@@ -754,8 +793,9 @@ export function FutureForecastModal() {
|
||||
tone,
|
||||
value: paceView.peakWindowText,
|
||||
};
|
||||
}, [detail.local_time, detail.peak?.first_h, detail.peak?.last_h, isToday, locale, paceView]);
|
||||
}, [detail.local_time, detail.peak?.first_h, detail.peak?.last_h, isToday, locale, paceView, showDeferredTodaySections]);
|
||||
const networkLeadView = useMemo(() => {
|
||||
if (!showDeferredTodaySections) return null;
|
||||
if (!isToday) return null;
|
||||
const delta = Number(detail.airport_vs_network_delta);
|
||||
const leadSignal = detail.network_lead_signal;
|
||||
@@ -797,7 +837,7 @@ export function FutureForecastModal() {
|
||||
tone,
|
||||
value: `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`,
|
||||
};
|
||||
}, [detail.airport_vs_network_delta, detail.network_lead_signal, detail.temp_symbol, isToday, locale]);
|
||||
}, [detail.airport_vs_network_delta, detail.network_lead_signal, detail.temp_symbol, isToday, locale, showDeferredTodaySections]);
|
||||
const isNoaaSettlement =
|
||||
detail.current?.settlement_source === "noaa" ||
|
||||
detail.current?.settlement_source_label === "NOAA";
|
||||
@@ -871,6 +911,7 @@ export function FutureForecastModal() {
|
||||
formatBucketLabel(marketScan?.temperature_bucket) !== "--" &&
|
||||
hottestBucketLabel === formatBucketLabel(marketScan?.temperature_bucket);
|
||||
const marketAwareUpperAirCue = useMemo(() => {
|
||||
if (!showDeferredTodaySections) return null;
|
||||
if (!isToday || (!upperAirSignal.source && !tafSignal.available)) return null;
|
||||
|
||||
const crowded = hottestMatchesSettlement && (topBucketProbability || 0) >= 0.3;
|
||||
@@ -1014,6 +1055,7 @@ export function FutureForecastModal() {
|
||||
topBucketProbability,
|
||||
upperAirSignal.heating_setup,
|
||||
upperAirSignal.source,
|
||||
showDeferredTodaySections,
|
||||
]);
|
||||
const topObservedTemp =
|
||||
detail.current?.max_so_far != null
|
||||
@@ -1038,22 +1080,25 @@ export function FutureForecastModal() {
|
||||
percent,
|
||||
};
|
||||
})();
|
||||
const displayedUpperAirSummary =
|
||||
marketAwareUpperAirCue?.summary || view.front.upperAirSummary;
|
||||
const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map(
|
||||
(metric, index) =>
|
||||
index === 0 &&
|
||||
(metric.label === "Trade cue" || metric.label === "交易动作") &&
|
||||
marketAwareUpperAirCue
|
||||
? {
|
||||
...metric,
|
||||
note: marketAwareUpperAirCue.note,
|
||||
tone: marketAwareUpperAirCue.tone,
|
||||
value: marketAwareUpperAirCue.value,
|
||||
}
|
||||
: metric,
|
||||
);
|
||||
const displayedUpperAirSummary = showDeferredTodaySections
|
||||
? marketAwareUpperAirCue?.summary || view.front.upperAirSummary
|
||||
: "";
|
||||
const displayedUpperAirMetrics = showDeferredTodaySections
|
||||
? (view.front.upperAirMetrics || []).map((metric, index) =>
|
||||
index === 0 &&
|
||||
(metric.label === "Trade cue" || metric.label === "交易动作") &&
|
||||
marketAwareUpperAirCue
|
||||
? {
|
||||
...metric,
|
||||
note: marketAwareUpperAirCue.note,
|
||||
tone: marketAwareUpperAirCue.tone,
|
||||
value: marketAwareUpperAirCue.value,
|
||||
}
|
||||
: metric,
|
||||
)
|
||||
: [];
|
||||
const localizedAiCommentaryLines = useMemo(() => {
|
||||
if (!showDeferredTodaySections) return [] as string[];
|
||||
const commentary = detail.dynamic_commentary || {};
|
||||
const headline = String(
|
||||
locale === "en-US" ? commentary.headline_en || "" : commentary.headline_zh || "",
|
||||
@@ -1065,8 +1110,9 @@ export function FutureForecastModal() {
|
||||
? bullets.map((item) => String(item || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
return [headline, ...cleanedBullets].filter(Boolean).slice(0, 3);
|
||||
}, [detail.dynamic_commentary, locale]);
|
||||
}, [detail.dynamic_commentary, locale, showDeferredTodaySections]);
|
||||
const todayTradeSummaryLines = useMemo(() => {
|
||||
if (!showDeferredTodaySections) return [] as string[];
|
||||
if (!isToday) return [] as string[];
|
||||
if (localizedAiCommentaryLines.length > 0) {
|
||||
return localizedAiCommentaryLines;
|
||||
@@ -1102,7 +1148,7 @@ export function FutureForecastModal() {
|
||||
);
|
||||
}
|
||||
return lines.slice(0, 3);
|
||||
}, [boundaryRiskView, isToday, locale, localizedAiCommentaryLines, networkLeadView, paceView]);
|
||||
}, [boundaryRiskView, isToday, locale, localizedAiCommentaryLines, networkLeadView, paceView, showDeferredTodaySections]);
|
||||
const syncStatusItems = [
|
||||
{
|
||||
key: "base",
|
||||
@@ -1379,7 +1425,7 @@ export function FutureForecastModal() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{paceView ? (
|
||||
{showDeferredTodaySections && paceView ? (
|
||||
<section className="future-v2-card future-v2-pace-card future-v2-focus-card">
|
||||
<div className="future-v2-card-head">
|
||||
<h4 className="future-v2-card-title">
|
||||
@@ -1497,6 +1543,24 @@ export function FutureForecastModal() {
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : isToday ? (
|
||||
<section className="future-v2-card future-v2-support-card">
|
||||
<div className="future-v2-card-head">
|
||||
<h4 className="future-v2-card-title">
|
||||
{locale === "en-US" ? "Current Pace" : "当前节奏"}
|
||||
</h4>
|
||||
<div className="future-v2-card-kicker">
|
||||
{locale === "en-US"
|
||||
? "Backfilling intraday pace context"
|
||||
: "正在补齐日内节奏上下文"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-trend-summary future-trend-summary-muted">
|
||||
{locale === "en-US"
|
||||
? "Expected-now pace, boundary risk, and airport-vs-network cues are loading in the background."
|
||||
: "预期此刻节奏、边界风险和机场对比站网信号正在后台补齐。"}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
</aside>
|
||||
@@ -1560,164 +1624,175 @@ export function FutureForecastModal() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="future-modal-section">
|
||||
<h3>{t("future.structureToday")}</h3>
|
||||
<div className="future-front-score">
|
||||
<div className="future-front-bar" style={barStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: "50%",
|
||||
width: "2px",
|
||||
background: "rgba(255, 255, 255, 0.2)",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="future-front-meta">
|
||||
<span className="future-front-pill">
|
||||
{t("future.judgement")}: {view.front.label}
|
||||
</span>
|
||||
<span className="future-front-pill">
|
||||
{t("future.confidence")}:{" "}
|
||||
{t(`confidence.${view.front.confidence}`)}
|
||||
</span>
|
||||
<span className="future-front-pill">
|
||||
{t("future.maxPrecip")}:{" "}
|
||||
{Math.round(view.front.precipMax)}%
|
||||
</span>
|
||||
</div>
|
||||
{todayTradeSummaryLines.length > 0 ? (
|
||||
<div className="future-trend-summary">
|
||||
{todayTradeSummaryLines.map((line, index) => (
|
||||
<div key={`${index}-${line}`}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="future-subsection-title">
|
||||
{locale === "en-US" ? "Surface Structure" : "近地面信号"}
|
||||
</div>
|
||||
<div className="future-trend-grid">
|
||||
{view.front.metrics.slice(0, 6).map((metric) => (
|
||||
<div key={metric.label} className="future-trend-card">
|
||||
<div className="future-trend-label">{metric.label}</div>
|
||||
{showDeferredTodaySections ? (
|
||||
<section className="future-modal-section">
|
||||
<h3>{t("future.structureToday")}</h3>
|
||||
<div className="future-front-score">
|
||||
<div className="future-front-bar" style={barStyle}>
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-value",
|
||||
metric.tone === "warm" && "warm",
|
||||
metric.tone === "cold" && "cold",
|
||||
)}
|
||||
>
|
||||
{metric.value}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: "50%",
|
||||
width: "2px",
|
||||
background: "rgba(255, 255, 255, 0.2)",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="future-front-meta">
|
||||
<span className="future-front-pill">
|
||||
{t("future.judgement")}: {view.front.label}
|
||||
</span>
|
||||
<span className="future-front-pill">
|
||||
{t("future.confidence")}:{" "}
|
||||
{t(`confidence.${view.front.confidence}`)}
|
||||
</span>
|
||||
<span className="future-front-pill">
|
||||
{t("future.maxPrecip")}:{" "}
|
||||
{Math.round(view.front.precipMax)}%
|
||||
</span>
|
||||
</div>
|
||||
{todayTradeSummaryLines.length > 0 ? (
|
||||
<div className="future-trend-summary">
|
||||
{todayTradeSummaryLines.map((line, index) => (
|
||||
<div key={`${index}-${line}`}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
{getTrendMetricVisual(metric) ? (
|
||||
) : null}
|
||||
</div>
|
||||
<div className="future-subsection-title">
|
||||
{locale === "en-US" ? "Surface Structure" : "近地面信号"}
|
||||
</div>
|
||||
<div className="future-trend-grid">
|
||||
{view.front.metrics.slice(0, 6).map((metric) => (
|
||||
<div key={metric.label} className="future-trend-card">
|
||||
<div className="future-trend-label">{metric.label}</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-meter",
|
||||
getTrendMetricVisual(metric)?.mode === "center" &&
|
||||
"center",
|
||||
"future-trend-value",
|
||||
metric.tone === "warm" && "warm",
|
||||
metric.tone === "cold" && "cold",
|
||||
)}
|
||||
>
|
||||
{getTrendMetricVisual(metric)?.mode === "center" ? (
|
||||
<span className="future-trend-meter-midline" />
|
||||
) : null}
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-meter-fill",
|
||||
getTrendMetricVisual(metric)?.tone === "warm" &&
|
||||
"warm",
|
||||
getTrendMetricVisual(metric)?.tone === "cold" &&
|
||||
"cold",
|
||||
)}
|
||||
style={{
|
||||
width: `${getTrendMetricVisual(metric)?.percent ?? 0}%`,
|
||||
}}
|
||||
/>
|
||||
{metric.value}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="future-trend-note">{metric.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="future-subsection-title">
|
||||
{locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"}
|
||||
</div>
|
||||
{displayedUpperAirSummary ? (
|
||||
<div className="future-trend-summary">
|
||||
{displayedUpperAirSummary}
|
||||
</div>
|
||||
) : (
|
||||
<div className="future-trend-summary future-trend-summary-muted">
|
||||
{locale === "en-US"
|
||||
? "Upper-air structure is temporarily unavailable for this city. For now, lean on surface structure and TAF timing."
|
||||
: "该城市当前暂无可用的高空结构数据,先以近地面结构和 TAF 时段作为主判断。"}
|
||||
</div>
|
||||
)}
|
||||
{displayedUpperAirMetrics.length > 0 ? (
|
||||
<div className="future-trend-grid">
|
||||
{displayedUpperAirMetrics.map((metric) => (
|
||||
<div key={metric.label} className="future-trend-card">
|
||||
<div className="future-trend-label">{metric.label}</div>
|
||||
{getTrendMetricVisual(metric) ? (
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-value",
|
||||
metric.tone === "warm" && "warm",
|
||||
metric.tone === "cold" && "cold",
|
||||
"future-trend-meter",
|
||||
getTrendMetricVisual(metric)?.mode === "center" &&
|
||||
"center",
|
||||
)}
|
||||
>
|
||||
{metric.value}
|
||||
</div>
|
||||
{getTrendMetricVisual(metric) ? (
|
||||
{getTrendMetricVisual(metric)?.mode === "center" ? (
|
||||
<span className="future-trend-meter-midline" />
|
||||
) : null}
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-meter",
|
||||
getTrendMetricVisual(metric)?.mode === "center" &&
|
||||
"center",
|
||||
"future-trend-meter-fill",
|
||||
getTrendMetricVisual(metric)?.tone === "warm" &&
|
||||
"warm",
|
||||
getTrendMetricVisual(metric)?.tone === "cold" &&
|
||||
"cold",
|
||||
)}
|
||||
style={{
|
||||
width: `${getTrendMetricVisual(metric)?.percent ?? 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="future-trend-note">{metric.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="future-subsection-title">
|
||||
{locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"}
|
||||
</div>
|
||||
{displayedUpperAirSummary ? (
|
||||
<div className="future-trend-summary">
|
||||
{displayedUpperAirSummary}
|
||||
</div>
|
||||
) : (
|
||||
<div className="future-trend-summary future-trend-summary-muted">
|
||||
{locale === "en-US"
|
||||
? "Upper-air structure is temporarily unavailable for this city. For now, lean on surface structure and TAF timing."
|
||||
: "该城市当前暂无可用的高空结构数据,先以近地面结构和 TAF 时段作为主判断。"}
|
||||
</div>
|
||||
)}
|
||||
{displayedUpperAirMetrics.length > 0 ? (
|
||||
<div className="future-trend-grid">
|
||||
{displayedUpperAirMetrics.map((metric) => (
|
||||
<div key={metric.label} className="future-trend-card">
|
||||
<div className="future-trend-label">{metric.label}</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-value",
|
||||
metric.tone === "warm" && "warm",
|
||||
metric.tone === "cold" && "cold",
|
||||
)}
|
||||
>
|
||||
{getTrendMetricVisual(metric)?.mode === "center" ? (
|
||||
<span className="future-trend-meter-midline" />
|
||||
) : null}
|
||||
{metric.value}
|
||||
</div>
|
||||
{getTrendMetricVisual(metric) ? (
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-meter-fill",
|
||||
getTrendMetricVisual(metric)?.tone === "warm" &&
|
||||
"warm",
|
||||
getTrendMetricVisual(metric)?.tone === "cold" &&
|
||||
"cold",
|
||||
"future-trend-meter",
|
||||
getTrendMetricVisual(metric)?.mode === "center" &&
|
||||
"center",
|
||||
)}
|
||||
style={{
|
||||
width: `${getTrendMetricVisual(metric)?.percent ?? 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="future-trend-note">{metric.note}</div>
|
||||
>
|
||||
{getTrendMetricVisual(metric)?.mode === "center" ? (
|
||||
<span className="future-trend-meter-midline" />
|
||||
) : null}
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-meter-fill",
|
||||
getTrendMetricVisual(metric)?.tone === "warm" &&
|
||||
"warm",
|
||||
getTrendMetricVisual(metric)?.tone === "cold" &&
|
||||
"cold",
|
||||
)}
|
||||
style={{
|
||||
width: `${getTrendMetricVisual(metric)?.percent ?? 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="future-trend-note">{metric.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="future-trend-card future-trend-card-empty">
|
||||
<div className="future-trend-label">
|
||||
{locale === "en-US" ? "Upper-air source" : "高空数据源"}
|
||||
</div>
|
||||
<div className="future-trend-value">
|
||||
{locale === "en-US" ? "Not available" : "暂不可用"}
|
||||
</div>
|
||||
<div className="future-trend-note">
|
||||
{locale === "en-US"
|
||||
? "No upper-air diagnostic feed is attached to this city right now."
|
||||
: "当前该城市未接入可用的高空诊断源,所以这里先保留说明卡片。"}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="future-trend-card future-trend-card-empty">
|
||||
<div className="future-trend-label">
|
||||
{locale === "en-US" ? "Upper-air source" : "高空数据源"}
|
||||
</div>
|
||||
<div className="future-trend-value">
|
||||
{locale === "en-US" ? "Not available" : "暂不可用"}
|
||||
</div>
|
||||
<div className="future-trend-note">
|
||||
{locale === "en-US"
|
||||
? "No upper-air diagnostic feed is attached to this city right now."
|
||||
: "当前该城市未接入可用的高空诊断源,所以这里先保留说明卡片。"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
</section>
|
||||
) : (
|
||||
<section className="future-modal-section">
|
||||
<h3>{t("future.structureToday")}</h3>
|
||||
<div className="future-trend-summary future-trend-summary-muted">
|
||||
{locale === "en-US"
|
||||
? "Surface structure, upper-air diagnostics, and trade commentary are loading after the primary chart."
|
||||
: "近地面结构、高空诊断和交易提示会在主图之后继续后台补齐。"}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -166,7 +166,7 @@ function HistoryChart() {
|
||||
export function HistoryModal() {
|
||||
const store = useDashboardStore();
|
||||
const { t, locale } = useI18n();
|
||||
const { data, error, isLoading, isOpen } = useHistoryData();
|
||||
const { data, error, isLoading, isOpen, isRecordsLoading, meta } = useHistoryData();
|
||||
const isPro = store.proAccess.subscriptionActive;
|
||||
const isProLoading = store.proAccess.loading;
|
||||
const isNoaaSettlement =
|
||||
@@ -233,6 +233,25 @@ export function HistoryModal() {
|
||||
city: store.selectedCity?.toUpperCase() || "",
|
||||
})}
|
||||
</h2>
|
||||
{meta?.mode === "preview" ? (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: "12px",
|
||||
marginLeft: "12px",
|
||||
}}
|
||||
>
|
||||
{isRecordsLoading
|
||||
? locale === "en-US"
|
||||
? "Loading full records in background..."
|
||||
: "完整历史正在后台补齐..."
|
||||
: meta.hasMore
|
||||
? locale === "en-US"
|
||||
? `Preview ${meta.previewCount}/${meta.fullCount}`
|
||||
: `预览 ${meta.previewCount}/${meta.fullCount}`
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
|
||||
@@ -6,11 +6,22 @@ import {
|
||||
DashboardStoreProvider,
|
||||
useDashboardStore,
|
||||
} from "@/hooks/useDashboardStore";
|
||||
import { preloadChartJs } from "@/hooks/useChart";
|
||||
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||
import { CitySidebar } from "@/components/dashboard/CitySidebar";
|
||||
import { DetailPanel } from "@/components/dashboard/DetailPanel";
|
||||
import { HeaderBar } from "@/components/dashboard/HeaderBar";
|
||||
|
||||
const loadHistoryModal = () =>
|
||||
import("@/components/dashboard/HistoryModal").then(
|
||||
(module) => module.HistoryModal,
|
||||
);
|
||||
|
||||
const loadFutureForecastModal = () =>
|
||||
import("@/components/dashboard/FutureForecastModal").then(
|
||||
(module) => module.FutureForecastModal,
|
||||
);
|
||||
|
||||
const MapCanvas = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/MapCanvas").then((module) => module.MapCanvas),
|
||||
@@ -21,10 +32,7 @@ const MapCanvas = dynamic(
|
||||
);
|
||||
|
||||
const HistoryModal = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/HistoryModal").then(
|
||||
(module) => module.HistoryModal,
|
||||
),
|
||||
loadHistoryModal,
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
@@ -32,10 +40,7 @@ const HistoryModal = dynamic(
|
||||
);
|
||||
|
||||
const FutureForecastModal = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/FutureForecastModal").then(
|
||||
(module) => module.FutureForecastModal,
|
||||
),
|
||||
loadFutureForecastModal,
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
@@ -77,6 +82,42 @@ function DashboardScreen() {
|
||||
};
|
||||
}, [store]);
|
||||
|
||||
useEffect(() => {
|
||||
const browserWindow = window as Window & {
|
||||
requestIdleCallback?: (
|
||||
cb: IdleRequestCallback,
|
||||
options?: IdleRequestOptions,
|
||||
) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
if (typeof browserWindow.requestIdleCallback === "function") {
|
||||
const handle = browserWindow.requestIdleCallback(() => {
|
||||
void loadHistoryModal();
|
||||
void loadFutureForecastModal();
|
||||
}, { timeout: 1200 });
|
||||
return () => {
|
||||
if (typeof browserWindow.cancelIdleCallback === "function") {
|
||||
browserWindow.cancelIdleCallback(handle);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadHistoryModal();
|
||||
void loadFutureForecastModal();
|
||||
}, 500);
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!store.selectedCity) return;
|
||||
void preloadChartJs();
|
||||
void loadHistoryModal();
|
||||
void loadFutureForecastModal();
|
||||
}, [store.selectedCity]);
|
||||
|
||||
// Avoid full-page flashing on initial load; only show this overlay for manual refresh.
|
||||
const showLoading =
|
||||
store.loadingState.cities ||
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Chart as ChartInstance, ChartConfiguration, ChartType } from "chart.js";
|
||||
|
||||
let chartModulePromise: Promise<typeof import("chart.js/auto")> | null = null;
|
||||
|
||||
export function preloadChartJs() {
|
||||
if (!chartModulePromise) {
|
||||
chartModulePromise = import("chart.js/auto");
|
||||
}
|
||||
return chartModulePromise;
|
||||
}
|
||||
|
||||
export function useChart<TType extends ChartType>(
|
||||
createConfig: () => ChartConfiguration<TType>,
|
||||
dependencies: React.DependencyList,
|
||||
@@ -16,7 +25,7 @@ export function useChart<TType extends ChartType>(
|
||||
let disposed = false;
|
||||
|
||||
const setupChart = async () => {
|
||||
const { Chart } = await import("chart.js/auto");
|
||||
const { Chart } = await preloadChartJs();
|
||||
if (disposed) return;
|
||||
|
||||
const config = createConfig();
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
CitySummary,
|
||||
DashboardState,
|
||||
HistoryPoint,
|
||||
HistoryPayload,
|
||||
HistoryPayloadMeta,
|
||||
HistoryState,
|
||||
LoadingState,
|
||||
MarketScan,
|
||||
@@ -58,7 +60,9 @@ function getInitialLoadingState(): LoadingState {
|
||||
return {
|
||||
cities: false,
|
||||
cityDetail: false,
|
||||
futureDeep: false,
|
||||
history: false,
|
||||
historyRecords: false,
|
||||
refresh: false,
|
||||
marketScan: false,
|
||||
};
|
||||
@@ -70,6 +74,8 @@ function getInitialHistoryState(): HistoryState {
|
||||
error: null,
|
||||
isOpen: false,
|
||||
loading: false,
|
||||
metaByCity: {},
|
||||
recordsLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,6 +246,20 @@ function scheduleWhenBrowserIdle(callback: () => void) {
|
||||
};
|
||||
}
|
||||
|
||||
function toHistoryMeta(payload: HistoryPayload): HistoryPayloadMeta {
|
||||
const history = Array.isArray(payload.history) ? payload.history : [];
|
||||
const previewCount = Number(payload.preview_count || history.length || 0);
|
||||
const fullCount = Number(payload.full_count || previewCount || 0);
|
||||
return {
|
||||
mode: payload.mode === "full" ? "full" : "preview",
|
||||
hasMore: payload.has_more === true,
|
||||
fullCount,
|
||||
previewCount,
|
||||
settlementSource: payload.settlement_source ?? null,
|
||||
settlementSourceLabel: payload.settlement_source_label ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function DashboardStoreProvider({
|
||||
children,
|
||||
}: {
|
||||
@@ -940,30 +960,107 @@ export function DashboardStoreProvider({
|
||||
error: null,
|
||||
isOpen: true,
|
||||
loading: false,
|
||||
recordsLoading: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const cityName = selectedCity;
|
||||
const cachedHistory = historyState.dataByCity[cityName];
|
||||
const cachedMeta = historyState.metaByCity[cityName];
|
||||
|
||||
if (cachedMeta && cachedHistory?.length) {
|
||||
setHistoryState((current) => ({
|
||||
...current,
|
||||
error: null,
|
||||
isOpen: true,
|
||||
loading: false,
|
||||
recordsLoading: cachedMeta.mode !== "full" && cachedMeta.hasMore,
|
||||
}));
|
||||
|
||||
if (cachedMeta.mode !== "full" && cachedMeta.hasMore) {
|
||||
void dashboardClient
|
||||
.getHistory(cityName, { includeRecords: true })
|
||||
.then((payload) => {
|
||||
if (selectedCityRef.current !== cityName) return;
|
||||
setHistoryState((current) => ({
|
||||
...current,
|
||||
dataByCity: {
|
||||
...current.dataByCity,
|
||||
[cityName]: payload.history,
|
||||
},
|
||||
metaByCity: {
|
||||
...current.metaByCity,
|
||||
[cityName]: toHistoryMeta(payload),
|
||||
},
|
||||
recordsLoading: false,
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
if (selectedCityRef.current !== cityName) return;
|
||||
setHistoryState((current) => ({
|
||||
...current,
|
||||
recordsLoading: false,
|
||||
}));
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setHistoryState((current) => ({
|
||||
...current,
|
||||
error: null,
|
||||
isOpen: true,
|
||||
loading: true,
|
||||
recordsLoading: false,
|
||||
}));
|
||||
try {
|
||||
const history = await dashboardClient.getHistory(selectedCity);
|
||||
const payload = await dashboardClient.getHistory(cityName);
|
||||
setHistoryState((current) => ({
|
||||
...current,
|
||||
dataByCity: {
|
||||
...current.dataByCity,
|
||||
[selectedCity]: history,
|
||||
[cityName]: payload.history,
|
||||
},
|
||||
metaByCity: {
|
||||
...current.metaByCity,
|
||||
[cityName]: toHistoryMeta(payload),
|
||||
},
|
||||
loading: false,
|
||||
recordsLoading: payload.has_more === true,
|
||||
}));
|
||||
|
||||
if (payload.has_more) {
|
||||
void dashboardClient
|
||||
.getHistory(cityName, { includeRecords: true })
|
||||
.then((fullPayload) => {
|
||||
if (selectedCityRef.current !== cityName) return;
|
||||
setHistoryState((current) => ({
|
||||
...current,
|
||||
dataByCity: {
|
||||
...current.dataByCity,
|
||||
[cityName]: fullPayload.history,
|
||||
},
|
||||
metaByCity: {
|
||||
...current.metaByCity,
|
||||
[cityName]: toHistoryMeta(fullPayload),
|
||||
},
|
||||
recordsLoading: false,
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
if (selectedCityRef.current !== cityName) return;
|
||||
setHistoryState((current) => ({
|
||||
...current,
|
||||
recordsLoading: false,
|
||||
}));
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setHistoryState((current) => ({
|
||||
...current,
|
||||
error: String(error),
|
||||
loading: false,
|
||||
recordsLoading: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -990,7 +1087,17 @@ export function DashboardStoreProvider({
|
||||
mapStopMotionRef.current();
|
||||
if (!selectedCity || !proAccess.subscriptionActive) return;
|
||||
const cityName = selectedCity;
|
||||
const cachedDetail = cityDetailsByName[selectedCity];
|
||||
let cachedDetail = cityDetailsByName[selectedCity];
|
||||
if (!cachedDetail) {
|
||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||
try {
|
||||
cachedDetail = await ensureCityDetail(cityName, false, "panel");
|
||||
} finally {
|
||||
if (selectedCityRef.current === cityName) {
|
||||
setLoadingState((current) => ({ ...current, cityDetail: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
const hasFullCachedDetail =
|
||||
detailSatisfiesDepth(cachedDetail, "full") &&
|
||||
!hasSparseDetailCoverage(cachedDetail, dateStr);
|
||||
@@ -1001,7 +1108,7 @@ export function DashboardStoreProvider({
|
||||
if (!hasFullCachedDetail || forceRefresh) {
|
||||
setLoadingState((current) => ({
|
||||
...current,
|
||||
refresh: true,
|
||||
futureDeep: true,
|
||||
}));
|
||||
void ensureCityDetail(cityName, true, "full")
|
||||
.catch(() => {})
|
||||
@@ -1009,7 +1116,7 @@ export function DashboardStoreProvider({
|
||||
if (selectedCityRef.current !== cityName) return;
|
||||
setLoadingState((current) => ({
|
||||
...current,
|
||||
refresh: false,
|
||||
futureDeep: false,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -1033,7 +1140,17 @@ export function DashboardStoreProvider({
|
||||
|
||||
mapStopMotionRef.current();
|
||||
const cityName = selectedCity;
|
||||
const cachedDetail = cityDetailsByName[cityName];
|
||||
let cachedDetail = cityDetailsByName[cityName];
|
||||
if (!cachedDetail) {
|
||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||
try {
|
||||
cachedDetail = await ensureCityDetail(cityName, false, "panel");
|
||||
} finally {
|
||||
if (selectedCityRef.current === cityName) {
|
||||
setLoadingState((current) => ({ ...current, cityDetail: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
const hasFullCachedDetail =
|
||||
detailSatisfiesDepth(cachedDetail, "full") &&
|
||||
!hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date);
|
||||
@@ -1051,9 +1168,29 @@ export function DashboardStoreProvider({
|
||||
|
||||
setLoadingState((current) => ({
|
||||
...current,
|
||||
refresh: needsDetailRefresh,
|
||||
futureDeep: needsDetailRefresh,
|
||||
marketScan: true,
|
||||
}));
|
||||
const initialTargetDate =
|
||||
cachedDetail?.local_date || selectedForecastDate || null;
|
||||
const initialMarketKey = getMarketScanCacheKey(
|
||||
cityName,
|
||||
initialTargetDate,
|
||||
);
|
||||
void ensureCityMarketScan(
|
||||
cityName,
|
||||
forceRefresh || !marketScanByCityName[initialMarketKey],
|
||||
null,
|
||||
initialTargetDate,
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (selectedCityRef.current !== cityName) return;
|
||||
setLoadingState((current) => ({
|
||||
...current,
|
||||
marketScan: false,
|
||||
}));
|
||||
});
|
||||
void ensureCityDetail(
|
||||
cityName,
|
||||
needsDetailRefresh,
|
||||
@@ -1063,14 +1200,6 @@ export function DashboardStoreProvider({
|
||||
if (selectedCityRef.current !== cityName) return;
|
||||
setSelectedForecastDate(detail.local_date);
|
||||
setFutureModalDate(detail.local_date);
|
||||
|
||||
const marketKey = getMarketScanCacheKey(cityName, detail.local_date);
|
||||
return ensureCityMarketScan(
|
||||
cityName,
|
||||
forceRefresh || !marketScanByCityName[marketKey],
|
||||
null,
|
||||
detail.local_date,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (selectedCityRef.current !== cityName) return;
|
||||
@@ -1083,8 +1212,7 @@ export function DashboardStoreProvider({
|
||||
if (selectedCityRef.current !== cityName) return;
|
||||
setLoadingState((current) => ({
|
||||
...current,
|
||||
refresh: false,
|
||||
marketScan: false,
|
||||
futureDeep: false,
|
||||
}));
|
||||
});
|
||||
},
|
||||
@@ -1160,5 +1288,7 @@ export function useHistoryData(name?: string | null) {
|
||||
error: store.historyState.error,
|
||||
isLoading: store.historyState.loading,
|
||||
isOpen: store.historyState.isOpen,
|
||||
isRecordsLoading: store.historyState.recordsLoading,
|
||||
meta: key ? store.historyState.metaByCity[key] || null : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import {
|
||||
CityListItem,
|
||||
MarketScan,
|
||||
CitySummary,
|
||||
HistoryPoint,
|
||||
HistoryPayload,
|
||||
} from "@/lib/dashboard-types";
|
||||
|
||||
const CACHE_KEY = "polyWeather_v1";
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
|
||||
const pendingHistoryRequests = new Map<string, Promise<HistoryPoint[]>>();
|
||||
const pendingHistoryRequests = new Map<string, Promise<HistoryPayload>>();
|
||||
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
|
||||
const pendingMarketScanRequests = new Map<string, Promise<MarketScan | null>>();
|
||||
|
||||
@@ -225,7 +225,7 @@ export const dashboardClient = {
|
||||
}
|
||||
|
||||
const request = fetchJson<{ market_scan?: MarketScan }>(
|
||||
`/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`,
|
||||
`/api/city/${normalizeCityName(cityName)}/market-scan?${params.toString()}`,
|
||||
)
|
||||
.then((data) => data.market_scan || null)
|
||||
.finally(() => {
|
||||
@@ -248,21 +248,40 @@ export const dashboardClient = {
|
||||
}
|
||||
|
||||
return fetchJson<{ market_scan?: MarketScan }>(
|
||||
`/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`,
|
||||
`/api/city/${normalizeCityName(cityName)}/market-scan?${params.toString()}`,
|
||||
).then((data) => data.market_scan || null);
|
||||
},
|
||||
|
||||
async getHistory(cityName: string) {
|
||||
const requestKey = normalizeCityName(cityName);
|
||||
async getHistory(cityName: string, options?: { includeRecords?: boolean }) {
|
||||
const includeRecords = options?.includeRecords === true;
|
||||
const requestKey = `${normalizeCityName(cityName)}::${
|
||||
includeRecords ? "full" : "preview"
|
||||
}`;
|
||||
const existing = pendingHistoryRequests.get(requestKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const request = fetchJson<{ history?: HistoryPoint[] }>(
|
||||
`/api/history/${requestKey}`,
|
||||
const params = new URLSearchParams();
|
||||
if (includeRecords) {
|
||||
params.set("include_records", "true");
|
||||
}
|
||||
|
||||
const request = fetchJson<HistoryPayload>(
|
||||
`/api/history/${normalizeCityName(cityName)}${
|
||||
params.size ? `?${params.toString()}` : ""
|
||||
}`,
|
||||
)
|
||||
.then((data) => data.history || [])
|
||||
.then((data) => ({
|
||||
...data,
|
||||
full_count: Number(data.full_count || 0),
|
||||
has_more: data.has_more === true,
|
||||
history: Array.isArray(data.history) ? data.history : [],
|
||||
mode: (data.mode === "full" ? "full" : "preview") as
|
||||
| "full"
|
||||
| "preview",
|
||||
preview_count: Number(data.preview_count || 0),
|
||||
}))
|
||||
.finally(() => {
|
||||
pendingHistoryRequests.delete(requestKey);
|
||||
});
|
||||
|
||||
@@ -494,19 +494,42 @@ export interface HistoryPoint {
|
||||
deb_at_peak_minus_12h_error?: number | null;
|
||||
}
|
||||
|
||||
export interface HistoryPayloadMeta {
|
||||
mode: "preview" | "full";
|
||||
hasMore: boolean;
|
||||
fullCount: number;
|
||||
previewCount: number;
|
||||
settlementSource?: string | null;
|
||||
settlementSourceLabel?: string | null;
|
||||
}
|
||||
|
||||
export interface HistoryPayload {
|
||||
history: HistoryPoint[];
|
||||
has_more?: boolean;
|
||||
full_count?: number;
|
||||
preview_count?: number;
|
||||
mode?: "preview" | "full";
|
||||
settlement_source?: string | null;
|
||||
settlement_source_label?: string | null;
|
||||
}
|
||||
|
||||
export interface LoadingState {
|
||||
cities: boolean;
|
||||
cityDetail: boolean;
|
||||
refresh: boolean;
|
||||
history: boolean;
|
||||
marketScan?: boolean;
|
||||
futureDeep?: boolean;
|
||||
historyRecords?: boolean;
|
||||
}
|
||||
|
||||
export interface HistoryState {
|
||||
isOpen: boolean;
|
||||
loading: boolean;
|
||||
recordsLoading: boolean;
|
||||
error: string | null;
|
||||
dataByCity: Record<string, HistoryPoint[]>;
|
||||
metaByCity: Record<string, HistoryPayloadMeta>;
|
||||
}
|
||||
|
||||
export interface ProAccessState {
|
||||
|
||||
Reference in New Issue
Block a user