feat: implement multi-source weather data collection system and dashboard frontend with integrated analysis services.
This commit is contained in:
@@ -169,3 +169,11 @@
|
|||||||
{"city": "hong kong", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 28.999999999999996, "raw_sigma": 0.75, "deb_prediction": 28.2, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.5, "HKO(港天文)": 29.0}, "max_so_far": 27.2, "observation": {"current_temp": 27.2, "humidity": 77.0, "wind_speed_kt": 5.4, "visibility_mi": null, "local_hour": 10.283333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 28, "p": 0.412}, {"v": 29, "p": 0.412}, {"v": 27, "p": 0.088}, {"v": 30, "p": 0.088}], "shadow_prob_snapshot": [{"v": 28, "p": 0.413}, {"v": 29, "p": 0.413}, {"v": 27, "p": 0.087}, {"v": 30, "p": 0.087}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 28.999999999999996, "calibrated_sigma": 0.7478013193453208}
|
{"city": "hong kong", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 28.999999999999996, "raw_sigma": 0.75, "deb_prediction": 28.2, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.5, "HKO(港天文)": 29.0}, "max_so_far": 27.2, "observation": {"current_temp": 27.2, "humidity": 77.0, "wind_speed_kt": 5.4, "visibility_mi": null, "local_hour": 10.283333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 28, "p": 0.412}, {"v": 29, "p": 0.412}, {"v": 27, "p": 0.088}, {"v": 30, "p": 0.088}], "shadow_prob_snapshot": [{"v": 28, "p": 0.413}, {"v": 29, "p": 0.413}, {"v": 27, "p": 0.087}, {"v": 30, "p": 0.087}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 28.999999999999996, "calibrated_sigma": 0.7478013193453208}
|
||||||
{"city": "taipei", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 30.689999999999998, "raw_sigma": 2.8574999999999995, "deb_prediction": 30.9, "ensemble": {"p10": 29.7, "median": 30.2, "p90": 30.6}, "multi_model": {"Open-Meteo": 30.9, "ECMWF": 30.9, "GFS": 32.9, "ICON": 30.9, "GEM": 28.9, "JMA": 30.8}, "max_so_far": 29.2, "observation": {"current_temp": 29.2, "humidity": 64.0, "wind_speed_kt": 1.2, "visibility_mi": null, "local_hour": 10.283333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 31, "p": 0.179}, {"v": 30, "p": 0.175}, {"v": 32, "p": 0.163}, {"v": 29, "p": 0.152}], "shadow_prob_snapshot": [{"v": 31, "p": 0.183}, {"v": 30, "p": 0.178}, {"v": 32, "p": 0.165}, {"v": 29, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 30.689999999999998, "calibrated_sigma": 2.778122088606427}
|
{"city": "taipei", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 30.689999999999998, "raw_sigma": 2.8574999999999995, "deb_prediction": 30.9, "ensemble": {"p10": 29.7, "median": 30.2, "p90": 30.6}, "multi_model": {"Open-Meteo": 30.9, "ECMWF": 30.9, "GFS": 32.9, "ICON": 30.9, "GEM": 28.9, "JMA": 30.8}, "max_so_far": 29.2, "observation": {"current_temp": 29.2, "humidity": 64.0, "wind_speed_kt": 1.2, "visibility_mi": null, "local_hour": 10.283333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 31, "p": 0.179}, {"v": 30, "p": 0.175}, {"v": 32, "p": 0.163}, {"v": 29, "p": 0.152}], "shadow_prob_snapshot": [{"v": 31, "p": 0.183}, {"v": 30, "p": 0.178}, {"v": 32, "p": 0.165}, {"v": 29, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 30.689999999999998, "calibrated_sigma": 2.778122088606427}
|
||||||
{"city": "hong kong", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 27.71, "raw_sigma": 0.30937500000000073, "deb_prediction": 27.6, "ensemble": {"p10": 28.1, "median": 28.2, "p90": 28.5}, "multi_model": {"Open-Meteo": 27.5, "HKO(港天文)": 29.0, "ECMWF": 27.0, "GFS": 27.2, "ICON": 27.5, "GEM": 27.1, "JMA": 27.6}, "max_so_far": 27.2, "observation": {"current_temp": 27.2, "humidity": 77.0, "wind_speed_kt": 5.4, "visibility_mi": null, "local_hour": 10.283333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 27, "p": 0.824}, {"v": 28, "p": 0.176}], "shadow_prob_snapshot": [{"v": 27, "p": 0.814}, {"v": 28, "p": 0.186}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.71, "calibrated_sigma": 0.32103944874938267}
|
{"city": "hong kong", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 27.71, "raw_sigma": 0.30937500000000073, "deb_prediction": 27.6, "ensemble": {"p10": 28.1, "median": 28.2, "p90": 28.5}, "multi_model": {"Open-Meteo": 27.5, "HKO(港天文)": 29.0, "ECMWF": 27.0, "GFS": 27.2, "ICON": 27.5, "GEM": 27.1, "JMA": 27.6}, "max_so_far": 27.2, "observation": {"current_temp": 27.2, "humidity": 77.0, "wind_speed_kt": 5.4, "visibility_mi": null, "local_hour": 10.283333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 27, "p": 0.824}, {"v": 28, "p": 0.176}], "shadow_prob_snapshot": [{"v": 27, "p": 0.814}, {"v": 28, "p": 0.186}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.71, "calibrated_sigma": 0.32103944874938267}
|
||||||
|
{"city": "ankara", "timestamp": "2026-04-13T10:20:00.000Z", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": 10.8, "raw_sigma": 2.5, "deb_prediction": 10.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 10.6, "ECMWF": 11.7, "GFS": 11.3, "ICON": 10.6, "GEM": 10.8, "JMA": 10.5}, "max_so_far": 9.0, "observation": {"current_temp": 9.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 13.566666666666666}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.195}, {"v": 10, "p": 0.186}, {"v": 12, "p": 0.175}, {"v": 9, "p": 0.152}], "shadow_prob_snapshot": [{"v": 11, "p": 0.199}, {"v": 10, "p": 0.189}, {"v": 12, "p": 0.177}, {"v": 9, "p": 0.152}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 10.8, "calibrated_sigma": 2.4191943428283595}
|
||||||
|
{"city": "ankara", "timestamp": "2026-04-13T10:20:00.000Z", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": 10.77, "raw_sigma": 3.7124999999999995, "deb_prediction": 10.9, "ensemble": {"p10": 9.8, "median": 10.7, "p90": 11.6}, "multi_model": {"Open-Meteo": 10.6, "ECMWF": 11.7, "GFS": 11.3, "ICON": 10.6, "GEM": 10.8, "JMA": 10.5}, "max_so_far": 9.0, "observation": {"current_temp": 9.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 13.583333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.15}, {"v": 10, "p": 0.148}, {"v": 12, "p": 0.143}, {"v": 9, "p": 0.135}], "shadow_prob_snapshot": [{"v": 11, "p": 0.173}, {"v": 10, "p": 0.168}, {"v": 12, "p": 0.16}, {"v": 9, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 10.77, "calibrated_sigma": 3.0}
|
||||||
|
{"city": "istanbul", "timestamp": "2026-04-13T13:20:00+03:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": 12.5, "raw_sigma": 1.7999999999999998, "deb_prediction": 12.2, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 12.5, "ECMWF": 12.4, "GFS": 12.3, "ICON": 12.5, "GEM": 13.5, "JMA": 10.4}, "max_so_far": 12.0, "observation": {"current_temp": 12.0, "humidity": 46.9, "wind_speed_kt": 14.0, "visibility_mi": null, "local_hour": 13.583333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 12, "p": 0.298}, {"v": 13, "p": 0.298}, {"v": 14, "p": 0.22}, {"v": 15, "p": 0.121}], "shadow_prob_snapshot": [{"v": 12, "p": 0.294}, {"v": 13, "p": 0.294}, {"v": 14, "p": 0.22}, {"v": 15, "p": 0.124}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 12.5, "calibrated_sigma": 1.8402601718515887}
|
||||||
|
{"city": "hong kong", "timestamp": "2026-04-13T18:20:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.47999999999999987, "deb_prediction": 27.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.5, "humidity": 76.0, "wind_speed_kt": 4.3, "visibility_mi": null, "local_hour": 18.583333333333332}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||||
|
{"city": "hong kong", "timestamp": "2026-04-13T18:20:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10952569444444488, "deb_prediction": 27.5, "ensemble": {"p10": 28.0, "median": 28.3, "p90": 28.6}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.5, "humidity": 76.0, "wind_speed_kt": 4.3, "visibility_mi": null, "local_hour": 18.583333333333332}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||||
|
{"city": "tokyo", "timestamp": "2026-04-13T10:00:00.000Z", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.4349999999999998, "deb_prediction": 21.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 20.3, "ECMWF": 22.8, "GFS": 22.0, "ICON": 22.6, "GEM": 23.2, "JMA": 20.3}, "max_so_far": 23.0, "observation": {"current_temp": 20.0, "humidity": null, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 19.583333333333332}, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||||
|
{"city": "hong kong", "timestamp": "2026-04-13T18:30:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.47999999999999987, "deb_prediction": 27.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.4, "humidity": 77.0, "wind_speed_kt": 2.2, "visibility_mi": null, "local_hour": 18.666666666666668}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||||
|
{"city": "hong kong", "timestamp": "2026-04-13T18:30:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10952569444444488, "deb_prediction": 27.5, "ensemble": {"p10": 28.0, "median": 28.3, "p90": 28.6}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.4, "humidity": 77.0, "wind_speed_kt": 2.2, "visibility_mi": null, "local_hour": 18.666666666666668}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import clsx from "clsx";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
||||||
import { useChart } from "@/hooks/useChart";
|
import { preloadChartJs, useChart } from "@/hooks/useChart";
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
|
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
|
||||||
@@ -360,6 +360,12 @@ export function DetailPanel() {
|
|||||||
: `${t("detail.todayAnalysis")} (Pro)`
|
: `${t("detail.todayAnalysis")} (Pro)`
|
||||||
}
|
}
|
||||||
onClick={() => handleFeatureAccess("today")}
|
onClick={() => handleFeatureAccess("today")}
|
||||||
|
onFocus={() => {
|
||||||
|
void preloadChartJs();
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
void preloadChartJs();
|
||||||
|
}}
|
||||||
disabled={!store.selectedCity}
|
disabled={!store.selectedCity}
|
||||||
>
|
>
|
||||||
{isPro
|
{isPro
|
||||||
@@ -373,6 +379,12 @@ export function DetailPanel() {
|
|||||||
isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
|
isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
|
||||||
}
|
}
|
||||||
onClick={() => handleFeatureAccess("history")}
|
onClick={() => handleFeatureAccess("history")}
|
||||||
|
onFocus={() => {
|
||||||
|
void preloadChartJs();
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
void preloadChartJs();
|
||||||
|
}}
|
||||||
disabled={!store.selectedCity}
|
disabled={!store.selectedCity}
|
||||||
>
|
>
|
||||||
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
|
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
|
|
||||||
import type { ChartConfiguration } from "chart.js";
|
import type { ChartConfiguration } from "chart.js";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { CSSProperties, useMemo } from "react";
|
import { CSSProperties, useEffect, useMemo, useState } from "react";
|
||||||
import { useChart } from "@/hooks/useChart";
|
import { useChart } from "@/hooks/useChart";
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
@@ -626,13 +626,47 @@ export function FutureForecastModal() {
|
|||||||
const dateStr = store.futureModalDate;
|
const dateStr = store.futureModalDate;
|
||||||
const isPro = store.proAccess.subscriptionActive;
|
const isPro = store.proAccess.subscriptionActive;
|
||||||
const isProLoading = store.proAccess.loading;
|
const isProLoading = store.proAccess.loading;
|
||||||
|
const [showDeferredTodaySections, setShowDeferredTodaySections] = useState(false);
|
||||||
|
|
||||||
if (!detail || !dateStr) return null;
|
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 isToday = dateStr === detail.local_date;
|
||||||
const detailDepth = detail.detail_depth || "full";
|
const detailDepth = detail.detail_depth || "full";
|
||||||
const isFullDetailReady = detailDepth === "full";
|
const isFullDetailReady = detailDepth === "full";
|
||||||
const isStructureSyncing = store.loadingState.refresh || !isFullDetailReady;
|
const isStructureSyncing = store.loadingState.futureDeep || !isFullDetailReady;
|
||||||
const isMarketSyncing = store.loadingState.marketScan;
|
const isMarketSyncing = store.loadingState.marketScan;
|
||||||
const isAnyLayerSyncing = isStructureSyncing || isMarketSyncing;
|
const isAnyLayerSyncing = isStructureSyncing || isMarketSyncing;
|
||||||
const view = getFutureModalView(detail, dateStr, locale);
|
const view = getFutureModalView(detail, dateStr, locale);
|
||||||
@@ -642,8 +676,11 @@ export function FutureForecastModal() {
|
|||||||
} as CSSProperties & { "--score-position": string };
|
} as CSSProperties & { "--score-position": string };
|
||||||
const weatherSummary = getWeatherSummary(detail, locale);
|
const weatherSummary = getWeatherSummary(detail, locale);
|
||||||
const paceView = useMemo(
|
const paceView = useMemo(
|
||||||
() => (isToday ? getTodayPaceView(detail, locale) : null),
|
() =>
|
||||||
[detail, isToday, locale],
|
isToday && showDeferredTodaySections
|
||||||
|
? getTodayPaceView(detail, locale)
|
||||||
|
: null,
|
||||||
|
[detail, isToday, locale, showDeferredTodaySections],
|
||||||
);
|
);
|
||||||
const probabilityView = useMemo(
|
const probabilityView = useMemo(
|
||||||
() => getProbabilityView(detail, dateStr),
|
() => getProbabilityView(detail, dateStr),
|
||||||
@@ -674,6 +711,7 @@ export function FutureForecastModal() {
|
|||||||
};
|
};
|
||||||
}, [modelView]);
|
}, [modelView]);
|
||||||
const boundaryRiskView = useMemo(() => {
|
const boundaryRiskView = useMemo(() => {
|
||||||
|
if (!showDeferredTodaySections) return null;
|
||||||
if (!isToday || !paceView) return null;
|
if (!isToday || !paceView) return null;
|
||||||
const selectedBucket = marketScan?.temperature_bucket || null;
|
const selectedBucket = marketScan?.temperature_bucket || null;
|
||||||
const bounds = parseBucketBoundaries(selectedBucket);
|
const bounds = parseBucketBoundaries(selectedBucket);
|
||||||
@@ -718,8 +756,9 @@ export function FutureForecastModal() {
|
|||||||
tone,
|
tone,
|
||||||
value: `${nearest.gap.toFixed(1)}${detail.temp_symbol}`,
|
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(() => {
|
const peakWindowStateView = useMemo(() => {
|
||||||
|
if (!showDeferredTodaySections) return null;
|
||||||
if (!isToday || !paceView) return null;
|
if (!isToday || !paceView) return null;
|
||||||
const firstHour = Number(detail.peak?.first_h);
|
const firstHour = Number(detail.peak?.first_h);
|
||||||
const lastHour = Number(detail.peak?.last_h);
|
const lastHour = Number(detail.peak?.last_h);
|
||||||
@@ -754,8 +793,9 @@ export function FutureForecastModal() {
|
|||||||
tone,
|
tone,
|
||||||
value: paceView.peakWindowText,
|
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(() => {
|
const networkLeadView = useMemo(() => {
|
||||||
|
if (!showDeferredTodaySections) return null;
|
||||||
if (!isToday) return null;
|
if (!isToday) return null;
|
||||||
const delta = Number(detail.airport_vs_network_delta);
|
const delta = Number(detail.airport_vs_network_delta);
|
||||||
const leadSignal = detail.network_lead_signal;
|
const leadSignal = detail.network_lead_signal;
|
||||||
@@ -797,7 +837,7 @@ export function FutureForecastModal() {
|
|||||||
tone,
|
tone,
|
||||||
value: `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`,
|
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 =
|
const isNoaaSettlement =
|
||||||
detail.current?.settlement_source === "noaa" ||
|
detail.current?.settlement_source === "noaa" ||
|
||||||
detail.current?.settlement_source_label === "NOAA";
|
detail.current?.settlement_source_label === "NOAA";
|
||||||
@@ -871,6 +911,7 @@ export function FutureForecastModal() {
|
|||||||
formatBucketLabel(marketScan?.temperature_bucket) !== "--" &&
|
formatBucketLabel(marketScan?.temperature_bucket) !== "--" &&
|
||||||
hottestBucketLabel === formatBucketLabel(marketScan?.temperature_bucket);
|
hottestBucketLabel === formatBucketLabel(marketScan?.temperature_bucket);
|
||||||
const marketAwareUpperAirCue = useMemo(() => {
|
const marketAwareUpperAirCue = useMemo(() => {
|
||||||
|
if (!showDeferredTodaySections) return null;
|
||||||
if (!isToday || (!upperAirSignal.source && !tafSignal.available)) return null;
|
if (!isToday || (!upperAirSignal.source && !tafSignal.available)) return null;
|
||||||
|
|
||||||
const crowded = hottestMatchesSettlement && (topBucketProbability || 0) >= 0.3;
|
const crowded = hottestMatchesSettlement && (topBucketProbability || 0) >= 0.3;
|
||||||
@@ -1014,6 +1055,7 @@ export function FutureForecastModal() {
|
|||||||
topBucketProbability,
|
topBucketProbability,
|
||||||
upperAirSignal.heating_setup,
|
upperAirSignal.heating_setup,
|
||||||
upperAirSignal.source,
|
upperAirSignal.source,
|
||||||
|
showDeferredTodaySections,
|
||||||
]);
|
]);
|
||||||
const topObservedTemp =
|
const topObservedTemp =
|
||||||
detail.current?.max_so_far != null
|
detail.current?.max_so_far != null
|
||||||
@@ -1038,22 +1080,25 @@ export function FutureForecastModal() {
|
|||||||
percent,
|
percent,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
const displayedUpperAirSummary =
|
const displayedUpperAirSummary = showDeferredTodaySections
|
||||||
marketAwareUpperAirCue?.summary || view.front.upperAirSummary;
|
? marketAwareUpperAirCue?.summary || view.front.upperAirSummary
|
||||||
const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map(
|
: "";
|
||||||
(metric, index) =>
|
const displayedUpperAirMetrics = showDeferredTodaySections
|
||||||
index === 0 &&
|
? (view.front.upperAirMetrics || []).map((metric, index) =>
|
||||||
(metric.label === "Trade cue" || metric.label === "交易动作") &&
|
index === 0 &&
|
||||||
marketAwareUpperAirCue
|
(metric.label === "Trade cue" || metric.label === "交易动作") &&
|
||||||
? {
|
marketAwareUpperAirCue
|
||||||
...metric,
|
? {
|
||||||
note: marketAwareUpperAirCue.note,
|
...metric,
|
||||||
tone: marketAwareUpperAirCue.tone,
|
note: marketAwareUpperAirCue.note,
|
||||||
value: marketAwareUpperAirCue.value,
|
tone: marketAwareUpperAirCue.tone,
|
||||||
}
|
value: marketAwareUpperAirCue.value,
|
||||||
: metric,
|
}
|
||||||
);
|
: metric,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
const localizedAiCommentaryLines = useMemo(() => {
|
const localizedAiCommentaryLines = useMemo(() => {
|
||||||
|
if (!showDeferredTodaySections) return [] as string[];
|
||||||
const commentary = detail.dynamic_commentary || {};
|
const commentary = detail.dynamic_commentary || {};
|
||||||
const headline = String(
|
const headline = String(
|
||||||
locale === "en-US" ? commentary.headline_en || "" : commentary.headline_zh || "",
|
locale === "en-US" ? commentary.headline_en || "" : commentary.headline_zh || "",
|
||||||
@@ -1065,8 +1110,9 @@ export function FutureForecastModal() {
|
|||||||
? bullets.map((item) => String(item || "").trim()).filter(Boolean)
|
? bullets.map((item) => String(item || "").trim()).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
return [headline, ...cleanedBullets].filter(Boolean).slice(0, 3);
|
return [headline, ...cleanedBullets].filter(Boolean).slice(0, 3);
|
||||||
}, [detail.dynamic_commentary, locale]);
|
}, [detail.dynamic_commentary, locale, showDeferredTodaySections]);
|
||||||
const todayTradeSummaryLines = useMemo(() => {
|
const todayTradeSummaryLines = useMemo(() => {
|
||||||
|
if (!showDeferredTodaySections) return [] as string[];
|
||||||
if (!isToday) return [] as string[];
|
if (!isToday) return [] as string[];
|
||||||
if (localizedAiCommentaryLines.length > 0) {
|
if (localizedAiCommentaryLines.length > 0) {
|
||||||
return localizedAiCommentaryLines;
|
return localizedAiCommentaryLines;
|
||||||
@@ -1102,7 +1148,7 @@ export function FutureForecastModal() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return lines.slice(0, 3);
|
return lines.slice(0, 3);
|
||||||
}, [boundaryRiskView, isToday, locale, localizedAiCommentaryLines, networkLeadView, paceView]);
|
}, [boundaryRiskView, isToday, locale, localizedAiCommentaryLines, networkLeadView, paceView, showDeferredTodaySections]);
|
||||||
const syncStatusItems = [
|
const syncStatusItems = [
|
||||||
{
|
{
|
||||||
key: "base",
|
key: "base",
|
||||||
@@ -1379,7 +1425,7 @@ export function FutureForecastModal() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{paceView ? (
|
{showDeferredTodaySections && paceView ? (
|
||||||
<section className="future-v2-card future-v2-pace-card future-v2-focus-card">
|
<section className="future-v2-card future-v2-pace-card future-v2-focus-card">
|
||||||
<div className="future-v2-card-head">
|
<div className="future-v2-card-head">
|
||||||
<h4 className="future-v2-card-title">
|
<h4 className="future-v2-card-title">
|
||||||
@@ -1497,6 +1543,24 @@ export function FutureForecastModal() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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}
|
) : null}
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
@@ -1560,164 +1624,175 @@ export function FutureForecastModal() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="future-modal-section">
|
{showDeferredTodaySections ? (
|
||||||
<h3>{t("future.structureToday")}</h3>
|
<section className="future-modal-section">
|
||||||
<div className="future-front-score">
|
<h3>{t("future.structureToday")}</h3>
|
||||||
<div className="future-front-bar" style={barStyle}>
|
<div className="future-front-score">
|
||||||
<div
|
<div className="future-front-bar" style={barStyle}>
|
||||||
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>
|
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
style={{
|
||||||
"future-trend-value",
|
position: "absolute",
|
||||||
metric.tone === "warm" && "warm",
|
top: 0,
|
||||||
metric.tone === "cold" && "cold",
|
bottom: 0,
|
||||||
)}
|
left: "50%",
|
||||||
>
|
width: "2px",
|
||||||
{metric.value}
|
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>
|
</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
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"future-trend-meter",
|
"future-trend-value",
|
||||||
getTrendMetricVisual(metric)?.mode === "center" &&
|
metric.tone === "warm" && "warm",
|
||||||
"center",
|
metric.tone === "cold" && "cold",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getTrendMetricVisual(metric)?.mode === "center" ? (
|
{metric.value}
|
||||||
<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>
|
</div>
|
||||||
) : null}
|
{getTrendMetricVisual(metric) ? (
|
||||||
<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
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"future-trend-value",
|
"future-trend-meter",
|
||||||
metric.tone === "warm" && "warm",
|
getTrendMetricVisual(metric)?.mode === "center" &&
|
||||||
metric.tone === "cold" && "cold",
|
"center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{metric.value}
|
{getTrendMetricVisual(metric)?.mode === "center" ? (
|
||||||
</div>
|
<span className="future-trend-meter-midline" />
|
||||||
{getTrendMetricVisual(metric) ? (
|
) : null}
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"future-trend-meter",
|
"future-trend-meter-fill",
|
||||||
getTrendMetricVisual(metric)?.mode === "center" &&
|
getTrendMetricVisual(metric)?.tone === "warm" &&
|
||||||
"center",
|
"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" ? (
|
{metric.value}
|
||||||
<span className="future-trend-meter-midline" />
|
</div>
|
||||||
) : null}
|
{getTrendMetricVisual(metric) ? (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"future-trend-meter-fill",
|
"future-trend-meter",
|
||||||
getTrendMetricVisual(metric)?.tone === "warm" &&
|
getTrendMetricVisual(metric)?.mode === "center" &&
|
||||||
"warm",
|
"center",
|
||||||
getTrendMetricVisual(metric)?.tone === "cold" &&
|
|
||||||
"cold",
|
|
||||||
)}
|
)}
|
||||||
style={{
|
>
|
||||||
width: `${getTrendMetricVisual(metric)?.percent ?? 0}%`,
|
{getTrendMetricVisual(metric)?.mode === "center" ? (
|
||||||
}}
|
<span className="future-trend-meter-midline" />
|
||||||
/>
|
) : null}
|
||||||
</div>
|
<div
|
||||||
) : null}
|
className={clsx(
|
||||||
<div className="future-trend-note">{metric.note}</div>
|
"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>
|
|
||||||
) : (
|
|
||||||
<div className="future-trend-card future-trend-card-empty">
|
|
||||||
<div className="future-trend-label">
|
|
||||||
{locale === "en-US" ? "Upper-air source" : "高空数据源"}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="future-trend-value">
|
)}
|
||||||
{locale === "en-US" ? "Not available" : "暂不可用"}
|
</>
|
||||||
</div>
|
</section>
|
||||||
<div className="future-trend-note">
|
) : (
|
||||||
{locale === "en-US"
|
<section className="future-modal-section">
|
||||||
? "No upper-air diagnostic feed is attached to this city right now."
|
<h3>{t("future.structureToday")}</h3>
|
||||||
: "当前该城市未接入可用的高空诊断源,所以这里先保留说明卡片。"}
|
<div className="future-trend-summary future-trend-summary-muted">
|
||||||
</div>
|
{locale === "en-US"
|
||||||
</div>
|
? "Surface structure, upper-air diagnostics, and trade commentary are loading after the primary chart."
|
||||||
)}
|
: "近地面结构、高空诊断和交易提示会在主图之后继续后台补齐。"}
|
||||||
</>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ function HistoryChart() {
|
|||||||
export function HistoryModal() {
|
export function HistoryModal() {
|
||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { data, error, isLoading, isOpen } = useHistoryData();
|
const { data, error, isLoading, isOpen, isRecordsLoading, meta } = useHistoryData();
|
||||||
const isPro = store.proAccess.subscriptionActive;
|
const isPro = store.proAccess.subscriptionActive;
|
||||||
const isProLoading = store.proAccess.loading;
|
const isProLoading = store.proAccess.loading;
|
||||||
const isNoaaSettlement =
|
const isNoaaSettlement =
|
||||||
@@ -233,6 +233,25 @@ export function HistoryModal() {
|
|||||||
city: store.selectedCity?.toUpperCase() || "",
|
city: store.selectedCity?.toUpperCase() || "",
|
||||||
})}
|
})}
|
||||||
</h2>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="modal-close"
|
className="modal-close"
|
||||||
|
|||||||
@@ -6,11 +6,22 @@ import {
|
|||||||
DashboardStoreProvider,
|
DashboardStoreProvider,
|
||||||
useDashboardStore,
|
useDashboardStore,
|
||||||
} from "@/hooks/useDashboardStore";
|
} from "@/hooks/useDashboardStore";
|
||||||
|
import { preloadChartJs } from "@/hooks/useChart";
|
||||||
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||||
import { CitySidebar } from "@/components/dashboard/CitySidebar";
|
import { CitySidebar } from "@/components/dashboard/CitySidebar";
|
||||||
import { DetailPanel } from "@/components/dashboard/DetailPanel";
|
import { DetailPanel } from "@/components/dashboard/DetailPanel";
|
||||||
import { HeaderBar } from "@/components/dashboard/HeaderBar";
|
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(
|
const MapCanvas = dynamic(
|
||||||
() =>
|
() =>
|
||||||
import("@/components/dashboard/MapCanvas").then((module) => module.MapCanvas),
|
import("@/components/dashboard/MapCanvas").then((module) => module.MapCanvas),
|
||||||
@@ -21,10 +32,7 @@ const MapCanvas = dynamic(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const HistoryModal = dynamic(
|
const HistoryModal = dynamic(
|
||||||
() =>
|
loadHistoryModal,
|
||||||
import("@/components/dashboard/HistoryModal").then(
|
|
||||||
(module) => module.HistoryModal,
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => null,
|
loading: () => null,
|
||||||
@@ -32,10 +40,7 @@ const HistoryModal = dynamic(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const FutureForecastModal = dynamic(
|
const FutureForecastModal = dynamic(
|
||||||
() =>
|
loadFutureForecastModal,
|
||||||
import("@/components/dashboard/FutureForecastModal").then(
|
|
||||||
(module) => module.FutureForecastModal,
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => null,
|
loading: () => null,
|
||||||
@@ -77,6 +82,42 @@ function DashboardScreen() {
|
|||||||
};
|
};
|
||||||
}, [store]);
|
}, [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.
|
// Avoid full-page flashing on initial load; only show this overlay for manual refresh.
|
||||||
const showLoading =
|
const showLoading =
|
||||||
store.loadingState.cities ||
|
store.loadingState.cities ||
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { Chart as ChartInstance, ChartConfiguration, ChartType } from "chart.js";
|
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>(
|
export function useChart<TType extends ChartType>(
|
||||||
createConfig: () => ChartConfiguration<TType>,
|
createConfig: () => ChartConfiguration<TType>,
|
||||||
dependencies: React.DependencyList,
|
dependencies: React.DependencyList,
|
||||||
@@ -16,7 +25,7 @@ export function useChart<TType extends ChartType>(
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
const setupChart = async () => {
|
const setupChart = async () => {
|
||||||
const { Chart } = await import("chart.js/auto");
|
const { Chart } = await preloadChartJs();
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
|
||||||
const config = createConfig();
|
const config = createConfig();
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
CitySummary,
|
CitySummary,
|
||||||
DashboardState,
|
DashboardState,
|
||||||
HistoryPoint,
|
HistoryPoint,
|
||||||
|
HistoryPayload,
|
||||||
|
HistoryPayloadMeta,
|
||||||
HistoryState,
|
HistoryState,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
MarketScan,
|
MarketScan,
|
||||||
@@ -58,7 +60,9 @@ function getInitialLoadingState(): LoadingState {
|
|||||||
return {
|
return {
|
||||||
cities: false,
|
cities: false,
|
||||||
cityDetail: false,
|
cityDetail: false,
|
||||||
|
futureDeep: false,
|
||||||
history: false,
|
history: false,
|
||||||
|
historyRecords: false,
|
||||||
refresh: false,
|
refresh: false,
|
||||||
marketScan: false,
|
marketScan: false,
|
||||||
};
|
};
|
||||||
@@ -70,6 +74,8 @@ function getInitialHistoryState(): HistoryState {
|
|||||||
error: null,
|
error: null,
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
loading: 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({
|
export function DashboardStoreProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -940,30 +960,107 @@ export function DashboardStoreProvider({
|
|||||||
error: null,
|
error: null,
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
recordsLoading: false,
|
||||||
}));
|
}));
|
||||||
return;
|
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) => ({
|
setHistoryState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
error: null,
|
error: null,
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
loading: true,
|
loading: true,
|
||||||
|
recordsLoading: false,
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
const history = await dashboardClient.getHistory(selectedCity);
|
const payload = await dashboardClient.getHistory(cityName);
|
||||||
setHistoryState((current) => ({
|
setHistoryState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
dataByCity: {
|
dataByCity: {
|
||||||
...current.dataByCity,
|
...current.dataByCity,
|
||||||
[selectedCity]: history,
|
[cityName]: payload.history,
|
||||||
|
},
|
||||||
|
metaByCity: {
|
||||||
|
...current.metaByCity,
|
||||||
|
[cityName]: toHistoryMeta(payload),
|
||||||
},
|
},
|
||||||
loading: false,
|
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) {
|
} catch (error) {
|
||||||
setHistoryState((current) => ({
|
setHistoryState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
error: String(error),
|
error: String(error),
|
||||||
loading: false,
|
loading: false,
|
||||||
|
recordsLoading: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -990,7 +1087,17 @@ export function DashboardStoreProvider({
|
|||||||
mapStopMotionRef.current();
|
mapStopMotionRef.current();
|
||||||
if (!selectedCity || !proAccess.subscriptionActive) return;
|
if (!selectedCity || !proAccess.subscriptionActive) return;
|
||||||
const cityName = selectedCity;
|
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 =
|
const hasFullCachedDetail =
|
||||||
detailSatisfiesDepth(cachedDetail, "full") &&
|
detailSatisfiesDepth(cachedDetail, "full") &&
|
||||||
!hasSparseDetailCoverage(cachedDetail, dateStr);
|
!hasSparseDetailCoverage(cachedDetail, dateStr);
|
||||||
@@ -1001,7 +1108,7 @@ export function DashboardStoreProvider({
|
|||||||
if (!hasFullCachedDetail || forceRefresh) {
|
if (!hasFullCachedDetail || forceRefresh) {
|
||||||
setLoadingState((current) => ({
|
setLoadingState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
refresh: true,
|
futureDeep: true,
|
||||||
}));
|
}));
|
||||||
void ensureCityDetail(cityName, true, "full")
|
void ensureCityDetail(cityName, true, "full")
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -1009,7 +1116,7 @@ export function DashboardStoreProvider({
|
|||||||
if (selectedCityRef.current !== cityName) return;
|
if (selectedCityRef.current !== cityName) return;
|
||||||
setLoadingState((current) => ({
|
setLoadingState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
refresh: false,
|
futureDeep: false,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1033,7 +1140,17 @@ export function DashboardStoreProvider({
|
|||||||
|
|
||||||
mapStopMotionRef.current();
|
mapStopMotionRef.current();
|
||||||
const cityName = selectedCity;
|
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 =
|
const hasFullCachedDetail =
|
||||||
detailSatisfiesDepth(cachedDetail, "full") &&
|
detailSatisfiesDepth(cachedDetail, "full") &&
|
||||||
!hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date);
|
!hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date);
|
||||||
@@ -1051,9 +1168,29 @@ export function DashboardStoreProvider({
|
|||||||
|
|
||||||
setLoadingState((current) => ({
|
setLoadingState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
refresh: needsDetailRefresh,
|
futureDeep: needsDetailRefresh,
|
||||||
marketScan: true,
|
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(
|
void ensureCityDetail(
|
||||||
cityName,
|
cityName,
|
||||||
needsDetailRefresh,
|
needsDetailRefresh,
|
||||||
@@ -1063,14 +1200,6 @@ export function DashboardStoreProvider({
|
|||||||
if (selectedCityRef.current !== cityName) return;
|
if (selectedCityRef.current !== cityName) return;
|
||||||
setSelectedForecastDate(detail.local_date);
|
setSelectedForecastDate(detail.local_date);
|
||||||
setFutureModalDate(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(() => {
|
.catch(() => {
|
||||||
if (selectedCityRef.current !== cityName) return;
|
if (selectedCityRef.current !== cityName) return;
|
||||||
@@ -1083,8 +1212,7 @@ export function DashboardStoreProvider({
|
|||||||
if (selectedCityRef.current !== cityName) return;
|
if (selectedCityRef.current !== cityName) return;
|
||||||
setLoadingState((current) => ({
|
setLoadingState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
refresh: false,
|
futureDeep: false,
|
||||||
marketScan: false,
|
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1160,5 +1288,7 @@ export function useHistoryData(name?: string | null) {
|
|||||||
error: store.historyState.error,
|
error: store.historyState.error,
|
||||||
isLoading: store.historyState.loading,
|
isLoading: store.historyState.loading,
|
||||||
isOpen: store.historyState.isOpen,
|
isOpen: store.historyState.isOpen,
|
||||||
|
isRecordsLoading: store.historyState.recordsLoading,
|
||||||
|
meta: key ? store.historyState.metaByCity[key] || null : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import {
|
|||||||
CityListItem,
|
CityListItem,
|
||||||
MarketScan,
|
MarketScan,
|
||||||
CitySummary,
|
CitySummary,
|
||||||
HistoryPoint,
|
HistoryPayload,
|
||||||
} from "@/lib/dashboard-types";
|
} from "@/lib/dashboard-types";
|
||||||
|
|
||||||
const CACHE_KEY = "polyWeather_v1";
|
const CACHE_KEY = "polyWeather_v1";
|
||||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
|
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 pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
|
||||||
const pendingMarketScanRequests = new Map<string, Promise<MarketScan | null>>();
|
const pendingMarketScanRequests = new Map<string, Promise<MarketScan | null>>();
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ export const dashboardClient = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const request = fetchJson<{ market_scan?: MarketScan }>(
|
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)
|
.then((data) => data.market_scan || null)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -248,21 +248,40 @@ export const dashboardClient = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return fetchJson<{ market_scan?: MarketScan }>(
|
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);
|
).then((data) => data.market_scan || null);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getHistory(cityName: string) {
|
async getHistory(cityName: string, options?: { includeRecords?: boolean }) {
|
||||||
const requestKey = normalizeCityName(cityName);
|
const includeRecords = options?.includeRecords === true;
|
||||||
|
const requestKey = `${normalizeCityName(cityName)}::${
|
||||||
|
includeRecords ? "full" : "preview"
|
||||||
|
}`;
|
||||||
const existing = pendingHistoryRequests.get(requestKey);
|
const existing = pendingHistoryRequests.get(requestKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = fetchJson<{ history?: HistoryPoint[] }>(
|
const params = new URLSearchParams();
|
||||||
`/api/history/${requestKey}`,
|
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(() => {
|
.finally(() => {
|
||||||
pendingHistoryRequests.delete(requestKey);
|
pendingHistoryRequests.delete(requestKey);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -494,19 +494,42 @@ export interface HistoryPoint {
|
|||||||
deb_at_peak_minus_12h_error?: number | null;
|
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 {
|
export interface LoadingState {
|
||||||
cities: boolean;
|
cities: boolean;
|
||||||
cityDetail: boolean;
|
cityDetail: boolean;
|
||||||
refresh: boolean;
|
refresh: boolean;
|
||||||
history: boolean;
|
history: boolean;
|
||||||
marketScan?: boolean;
|
marketScan?: boolean;
|
||||||
|
futureDeep?: boolean;
|
||||||
|
historyRecords?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryState {
|
export interface HistoryState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
recordsLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
dataByCity: Record<string, HistoryPoint[]>;
|
dataByCity: Record<string, HistoryPoint[]>;
|
||||||
|
metaByCity: Record<string, HistoryPayloadMeta>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProAccessState {
|
export interface ProAccessState {
|
||||||
|
|||||||
@@ -764,9 +764,10 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
results: Dict,
|
results: Dict,
|
||||||
city_lower: str,
|
city_lower: str,
|
||||||
*,
|
*,
|
||||||
|
include_mgm: bool = True,
|
||||||
include_nearby: bool = True,
|
include_nearby: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
if city_lower not in self.TURKISH_PROVINCES:
|
if not include_mgm or city_lower not in self.TURKISH_PROVINCES:
|
||||||
return
|
return
|
||||||
istno, province = self.TURKISH_PROVINCES[city_lower]
|
istno, province = self.TURKISH_PROVINCES[city_lower]
|
||||||
mgm_data = self.fetch_from_mgm(istno)
|
mgm_data = self.fetch_from_mgm(istno)
|
||||||
@@ -929,6 +930,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
include_nearby: bool = True,
|
include_nearby: bool = True,
|
||||||
include_ensemble: bool = True,
|
include_ensemble: bool = True,
|
||||||
include_multi_model: bool = True,
|
include_multi_model: bool = True,
|
||||||
|
include_mgm: bool = True,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""
|
||||||
Fetch weather data from all available sources
|
Fetch weather data from all available sources
|
||||||
@@ -970,6 +972,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
self._attach_turkish_mgm_data(
|
self._attach_turkish_mgm_data(
|
||||||
results,
|
results,
|
||||||
city_lower,
|
city_lower,
|
||||||
|
include_mgm=include_mgm,
|
||||||
include_nearby=include_nearby,
|
include_nearby=include_nearby,
|
||||||
)
|
)
|
||||||
if include_nearby:
|
if include_nearby:
|
||||||
@@ -1011,6 +1014,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
self._attach_turkish_mgm_data(
|
self._attach_turkish_mgm_data(
|
||||||
results,
|
results,
|
||||||
city_lower,
|
city_lower,
|
||||||
|
include_mgm=include_mgm,
|
||||||
include_nearby=include_nearby,
|
include_nearby=include_nearby,
|
||||||
)
|
)
|
||||||
if include_nearby:
|
if include_nearby:
|
||||||
|
|||||||
+41
-9
@@ -88,6 +88,8 @@ def _analysis_cache_key(city: str, detail_mode: str = "full") -> str:
|
|||||||
normalized_raw = str(detail_mode or "").strip().lower()
|
normalized_raw = str(detail_mode or "").strip().lower()
|
||||||
if normalized_raw == "panel":
|
if normalized_raw == "panel":
|
||||||
normalized_mode = "panel"
|
normalized_mode = "panel"
|
||||||
|
elif normalized_raw == "market":
|
||||||
|
normalized_mode = "market"
|
||||||
elif normalized_raw == "nearby":
|
elif normalized_raw == "nearby":
|
||||||
normalized_mode = "nearby"
|
normalized_mode = "nearby"
|
||||||
else:
|
else:
|
||||||
@@ -98,7 +100,7 @@ def _analysis_cache_key(city: str, detail_mode: str = "full") -> str:
|
|||||||
def _get_cached_analysis(
|
def _get_cached_analysis(
|
||||||
city: str,
|
city: str,
|
||||||
ttl: int,
|
ttl: int,
|
||||||
detail_modes: tuple[str, ...] = ("panel", "nearby", "full"),
|
detail_modes: tuple[str, ...] = ("panel", "market", "nearby", "full"),
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
now_ts = _time.time()
|
now_ts = _time.time()
|
||||||
freshest_payload: Optional[Dict[str, Any]] = None
|
freshest_payload: Optional[Dict[str, Any]] = None
|
||||||
@@ -1094,6 +1096,8 @@ def _analyze(
|
|||||||
normalized_detail_mode_raw = str(detail_mode or "full").strip().lower()
|
normalized_detail_mode_raw = str(detail_mode or "full").strip().lower()
|
||||||
if normalized_detail_mode_raw == "panel":
|
if normalized_detail_mode_raw == "panel":
|
||||||
normalized_detail_mode = "panel"
|
normalized_detail_mode = "panel"
|
||||||
|
elif normalized_detail_mode_raw == "market":
|
||||||
|
normalized_detail_mode = "market"
|
||||||
elif normalized_detail_mode_raw == "nearby":
|
elif normalized_detail_mode_raw == "nearby":
|
||||||
normalized_detail_mode = "nearby"
|
normalized_detail_mode = "nearby"
|
||||||
else:
|
else:
|
||||||
@@ -1126,6 +1130,7 @@ def _analyze(
|
|||||||
|
|
||||||
# ── 1. Fetch raw data ──
|
# ── 1. Fetch raw data ──
|
||||||
is_panel_mode = normalized_detail_mode == "panel"
|
is_panel_mode = normalized_detail_mode == "panel"
|
||||||
|
is_market_mode = normalized_detail_mode == "market"
|
||||||
is_nearby_mode = normalized_detail_mode == "nearby"
|
is_nearby_mode = normalized_detail_mode == "nearby"
|
||||||
|
|
||||||
raw = _weather.fetch_all_sources(
|
raw = _weather.fetch_all_sources(
|
||||||
@@ -1133,10 +1138,11 @@ def _analyze(
|
|||||||
lat=lat,
|
lat=lat,
|
||||||
lon=lon,
|
lon=lon,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
include_taf=not is_panel_mode and not is_nearby_mode,
|
include_taf=not is_panel_mode and not is_nearby_mode and not is_market_mode,
|
||||||
include_nearby=not is_panel_mode,
|
include_nearby=not is_panel_mode and not is_market_mode,
|
||||||
include_ensemble=not is_panel_mode and not is_nearby_mode,
|
include_ensemble=not is_panel_mode and not is_nearby_mode and not is_market_mode,
|
||||||
include_multi_model=not is_panel_mode and not is_nearby_mode,
|
include_multi_model=not is_panel_mode and not is_nearby_mode,
|
||||||
|
include_mgm=not is_market_mode,
|
||||||
)
|
)
|
||||||
om = raw.get("open-meteo", {})
|
om = raw.get("open-meteo", {})
|
||||||
metar = raw.get("metar", {})
|
metar = raw.get("metar", {})
|
||||||
@@ -1160,7 +1166,7 @@ def _analyze(
|
|||||||
risk = CITY_RISK_PROFILES.get(city, {})
|
risk = CITY_RISK_PROFILES.get(city, {})
|
||||||
network_snapshot = (
|
network_snapshot = (
|
||||||
build_country_network_snapshot(city, raw)
|
build_country_network_snapshot(city, raw)
|
||||||
if not is_panel_mode
|
if not is_panel_mode and not is_market_mode
|
||||||
else {}
|
else {}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1628,7 +1634,7 @@ def _analyze(
|
|||||||
first_peak_h,
|
first_peak_h,
|
||||||
last_peak_h,
|
last_peak_h,
|
||||||
)
|
)
|
||||||
if not is_panel_mode and not is_nearby_mode
|
if not is_panel_mode and not is_nearby_mode and not is_market_mode
|
||||||
else {}
|
else {}
|
||||||
)
|
)
|
||||||
taf_signal = (
|
taf_signal = (
|
||||||
@@ -1640,7 +1646,7 @@ def _analyze(
|
|||||||
first_peak_h,
|
first_peak_h,
|
||||||
last_peak_h,
|
last_peak_h,
|
||||||
)
|
)
|
||||||
if not is_panel_mode and not is_nearby_mode
|
if not is_panel_mode and not is_nearby_mode and not is_market_mode
|
||||||
else {"available": False}
|
else {"available": False}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1790,7 +1796,15 @@ def _analyze(
|
|||||||
# ── Assemble result ──
|
# ── Assemble result ──
|
||||||
city_meta = CITIES.get(city, {}) or {}
|
city_meta = CITIES.get(city, {}) or {}
|
||||||
result = {
|
result = {
|
||||||
"detail_depth": "panel" if is_panel_mode else "nearby" if is_nearby_mode else "full",
|
"detail_depth": (
|
||||||
|
"panel"
|
||||||
|
if is_panel_mode
|
||||||
|
else "market"
|
||||||
|
if is_market_mode
|
||||||
|
else "nearby"
|
||||||
|
if is_nearby_mode
|
||||||
|
else "full"
|
||||||
|
),
|
||||||
"name": city,
|
"name": city,
|
||||||
"display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()),
|
"display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()),
|
||||||
"lat": lat,
|
"lat": lat,
|
||||||
@@ -2222,7 +2236,7 @@ def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_city_detail_payload(
|
def _build_city_market_scan_payload(
|
||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
market_slug: Optional[str] = None,
|
market_slug: Optional[str] = None,
|
||||||
target_date: Optional[str] = None,
|
target_date: Optional[str] = None,
|
||||||
@@ -2313,6 +2327,24 @@ def _build_city_detail_payload(
|
|||||||
market_scan["anchor_high"] = anchor_temp
|
market_scan["anchor_high"] = anchor_temp
|
||||||
market_scan["anchor_settlement"] = anchor_settlement
|
market_scan["anchor_settlement"] = anchor_settlement
|
||||||
market_scan["open_meteo_settlement"] = anchor_settlement
|
market_scan["open_meteo_settlement"] = anchor_settlement
|
||||||
|
return {
|
||||||
|
"market_scan": market_scan,
|
||||||
|
"selected_date": selected_date or data.get("local_date"),
|
||||||
|
"fetched_at": data.get("updated_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_city_detail_payload(
|
||||||
|
data: Dict[str, Any],
|
||||||
|
market_slug: Optional[str] = None,
|
||||||
|
target_date: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
market_payload = _build_city_market_scan_payload(
|
||||||
|
data,
|
||||||
|
market_slug=market_slug,
|
||||||
|
target_date=target_date,
|
||||||
|
)
|
||||||
|
market_scan = market_payload.get("market_scan")
|
||||||
return {
|
return {
|
||||||
"city": data.get("name"),
|
"city": data.get("name"),
|
||||||
"fetched_at": data.get("updated_at"),
|
"fetched_at": data.get("updated_at"),
|
||||||
|
|||||||
+42
-2
@@ -21,6 +21,7 @@ from web.analysis_service import (
|
|||||||
_analyze,
|
_analyze,
|
||||||
_analyze_summary,
|
_analyze_summary,
|
||||||
_build_city_detail_payload,
|
_build_city_detail_payload,
|
||||||
|
_build_city_market_scan_payload,
|
||||||
_build_city_summary_payload,
|
_build_city_summary_payload,
|
||||||
)
|
)
|
||||||
from web.core import (
|
from web.core import (
|
||||||
@@ -88,6 +89,7 @@ DEFAULT_PREWARM_CITIES = [
|
|||||||
"paris",
|
"paris",
|
||||||
"madrid",
|
"madrid",
|
||||||
]
|
]
|
||||||
|
HISTORY_PREVIEW_DAY_LIMIT = 21
|
||||||
|
|
||||||
|
|
||||||
def _parse_snapshot_dt(value: object) -> Optional[datetime]:
|
def _parse_snapshot_dt(value: object) -> Optional[datetime]:
|
||||||
@@ -445,7 +447,11 @@ async def city_detail(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/history/{name}")
|
@router.get("/api/history/{name}")
|
||||||
async def city_history(request: Request, name: str):
|
async def city_history(
|
||||||
|
request: Request,
|
||||||
|
name: str,
|
||||||
|
include_records: bool = False,
|
||||||
|
):
|
||||||
_assert_entitlement(request)
|
_assert_entitlement(request)
|
||||||
city = _normalize_city_or_404(name)
|
city = _normalize_city_or_404(name)
|
||||||
|
|
||||||
@@ -486,12 +492,23 @@ async def city_history(request: Request, name: str):
|
|||||||
if not city_data:
|
if not city_data:
|
||||||
return {
|
return {
|
||||||
"history": [],
|
"history": [],
|
||||||
|
"mode": "full" if include_records else "preview",
|
||||||
|
"has_more": False,
|
||||||
|
"full_count": 0,
|
||||||
|
"preview_count": 0,
|
||||||
"settlement_source": source,
|
"settlement_source": source,
|
||||||
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
|
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
all_days = sorted(city_data.keys())
|
||||||
|
selected_days = (
|
||||||
|
all_days
|
||||||
|
if include_records
|
||||||
|
else all_days[-HISTORY_PREVIEW_DAY_LIMIT:]
|
||||||
|
)
|
||||||
out = []
|
out = []
|
||||||
for day, rec in sorted(city_data.items()):
|
for day in selected_days:
|
||||||
|
rec = city_data.get(day, {})
|
||||||
if not isinstance(rec, dict):
|
if not isinstance(rec, dict):
|
||||||
rec = {}
|
rec = {}
|
||||||
|
|
||||||
@@ -539,6 +556,10 @@ async def city_history(request: Request, name: str):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"history": out,
|
"history": out,
|
||||||
|
"mode": "full" if include_records else "preview",
|
||||||
|
"has_more": len(all_days) > len(selected_days),
|
||||||
|
"full_count": len(all_days),
|
||||||
|
"preview_count": len(out),
|
||||||
"settlement_source": source,
|
"settlement_source": source,
|
||||||
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
|
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
|
||||||
}
|
}
|
||||||
@@ -1076,3 +1097,22 @@ async def city_detail_aggregate(
|
|||||||
market_slug,
|
market_slug,
|
||||||
target_date,
|
target_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/city/{name}/market-scan")
|
||||||
|
async def city_market_scan(
|
||||||
|
request: Request,
|
||||||
|
name: str,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
market_slug: Optional[str] = None,
|
||||||
|
target_date: Optional[str] = None,
|
||||||
|
):
|
||||||
|
_assert_entitlement(request)
|
||||||
|
city = _normalize_city_or_404(name)
|
||||||
|
data = await run_in_threadpool(_analyze, city, force_refresh, False, "market")
|
||||||
|
return await run_in_threadpool(
|
||||||
|
_build_city_market_scan_payload,
|
||||||
|
data,
|
||||||
|
market_slug,
|
||||||
|
target_date,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user