diff --git a/frontend/app/probabilities/page.tsx b/frontend/app/probabilities/page.tsx deleted file mode 100644 index a972ce87..00000000 --- a/frontend/app/probabilities/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { Metadata } from "next"; -import { ProbabilityHubPage } from "@/components/probability-hub/ProbabilityHubPage"; - -export const metadata: Metadata = { - title: "PolyWeather - 概率判断总览", - description: "集中查看 52 个城市的 EMOS 概率判断与市场合约桶对比。", -}; - -export default function ProbabilitiesPage() { - return ; -} diff --git a/frontend/components/dashboard/HeaderBar.tsx b/frontend/components/dashboard/HeaderBar.tsx index 85701622..53866184 100644 --- a/frontend/components/dashboard/HeaderBar.tsx +++ b/frontend/components/dashboard/HeaderBar.tsx @@ -9,7 +9,6 @@ import { RotateCw, BookOpen, Sparkles, - BarChart3, House, } from "lucide-react"; import { useDashboardStore } from "@/hooks/useDashboardStore"; @@ -44,8 +43,6 @@ export function HeaderBar({ const docsActive = pathname?.startsWith("/docs"); const homeHref = "/"; const homeActive = pathname === "/"; - const probabilityHubHref = "/probabilities"; - const probabilityHubActive = pathname?.startsWith("/probabilities"); const trialPromoLabel = locale === "en-US" ? "New users get 3-day Pro trial" @@ -154,16 +151,6 @@ export function HeaderBar({ {t("header.docs")} - - - {t("header.probabilityHub")} - - = { - high: 0, - medium: 1, - low: 2, - }; - - return [...cities].sort((a, b) => { - const riskDelta = - (riskOrder[String(a.risk_level || "").toLowerCase()] ?? 9) - - (riskOrder[String(b.risk_level || "").toLowerCase()] ?? 9); - if (riskDelta !== 0) return riskDelta; - return String(a.display_name || a.name).localeCompare( - String(b.display_name || b.name), - ); - }); -} - -function getRiskRank(level?: string | null) { - const riskOrder: Record = { - high: 0, - medium: 1, - low: 2, - }; - return riskOrder[String(level || "").toLowerCase()] ?? 9; -} - -function getProbabilityPeak(detail?: CityDetail | null) { - const buckets = Array.isArray(detail?.probabilities?.distribution_all) - ? detail.probabilities?.distribution_all - : Array.isArray(detail?.probabilities?.distribution) - ? detail.probabilities?.distribution - : []; - return buckets.reduce((best, bucket) => { - const next = Number(bucket?.probability ?? -1); - return next > best ? next : best; - }, -1); -} - -function getPositiveEdge(detail?: CityDetail | null) { - const analysis = detail?.market_scan?.price_analysis; - const yesEdge = Number(analysis?.yes?.edge ?? Number.NEGATIVE_INFINITY); - const noEdge = Number(analysis?.no?.edge ?? Number.NEGATIVE_INFINITY); - return Math.max(yesEdge, noEdge, Number.NEGATIVE_INFINITY); -} - -function isActiveMarketScan(detail?: CityDetail | null) { - const scan = detail?.market_scan; - if (!scan?.available) return false; - const primary = scan.primary_market as - | ({ - active?: boolean | null; - closed?: boolean | null; - accepting_orders?: boolean | null; - tradable?: boolean | null; - ended_at_utc?: string | null; - } & Record) - | null - | undefined; - if (!primary) return false; - if (primary.closed === true) return false; - if (primary.active === false) return false; - if (primary.accepting_orders === false) return false; - if (primary.tradable === false) return false; - if (primary.ended_at_utc) { - const endedAt = new Date(String(primary.ended_at_utc)).getTime(); - if (Number.isFinite(endedAt) && endedAt <= Date.now()) { - return false; - } - } - return true; -} - -function hasMarket(detail?: CityDetail | null) { - const scan = detail?.market_scan; - return Boolean( - isActiveMarketScan(detail) && - scan && - ((Array.isArray(scan.all_buckets) && scan.all_buckets.length > 0) || - (Array.isArray(scan.top_buckets) && scan.top_buckets.length > 0)), - ); -} - -function ProbabilityHubScreen() { - const { locale } = useI18n(); - const [cities, setCities] = useState([]); - const [detailsByName, setDetailsByName] = useState>({}); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); - const [lastUpdatedAt, setLastUpdatedAt] = useState(null); - const [lastMarketUpdatedAt, setLastMarketUpdatedAt] = useState(null); - const [filterMode, setFilterMode] = useState("all"); - const [sortMode, setSortMode] = useState("risk"); - const [visibleCityNames, setVisibleCityNames] = useState>(new Set()); - const cardNodesRef = useRef(new Map()); - const initialMarketRequestsRef = useRef(new Set()); - const marketRefreshInFlightRef = useRef(false); - const detailsByNameRef = useRef(detailsByName); - const citiesRef = useRef(cities); - - useEffect(() => { - detailsByNameRef.current = detailsByName; - }, [detailsByName]); - - useEffect(() => { - citiesRef.current = cities; - }, [cities]); - - const fetchCityDetails = useCallback( - async (cityList: CityListItem[], force: boolean) => { - const fetched: Record = {}; - - for (let index = 0; index < cityList.length; index += DETAIL_BATCH_SIZE) { - const batch = cityList.slice(index, index + DETAIL_BATCH_SIZE); - const results = await Promise.allSettled( - batch.map((city) => - dashboardClient.getCityDetail(city.name, { - depth: "panel", - force, - }), - ), - ); - - const patch: Record = {}; - results.forEach((result, batchIndex) => { - if (result.status !== "fulfilled") return; - const detail = result.value; - fetched[batch[batchIndex].name] = detail; - patch[batch[batchIndex].name] = detail; - }); - - if (Object.keys(patch).length) { - setDetailsByName((previous) => ({ - ...previous, - ...patch, - })); - } - } - - return fetched; - }, - [], - ); - - const refreshMarketScans = useCallback(async ( - sourceDetails?: Record, - sourceCities?: CityListItem[], - ) => { - if (marketRefreshInFlightRef.current) return; - const detailMap = sourceDetails ? { ...sourceDetails } : { ...detailsByName }; - const cityList = sourceCities || cities; - const loadedCities = cityList.filter((city) => detailMap[city.name]); - if (!loadedCities.length) return; - - let touched = false; - marketRefreshInFlightRef.current = true; - - try { - for (let index = 0; index < loadedCities.length; index += DETAIL_BATCH_SIZE) { - const batch = loadedCities.slice(index, index + DETAIL_BATCH_SIZE); - const results = await Promise.allSettled( - batch.map((city) => - dashboardClient.getCityMarketScan(city.name, { - force: false, - targetDate: detailMap[city.name]?.local_date || null, - marketSlug: detailMap[city.name]?.market_scan?.selected_slug || null, - }), - ), - ); - - const patch: Record = {}; - results.forEach((result, batchIndex) => { - if (result.status !== "fulfilled") return; - const city = batch[batchIndex]; - const previous = detailMap[city.name] || detailsByName[city.name]; - if (!previous) return; - const nextMarketScan = result.value.market_scan || previous.market_scan; - if (!nextMarketScan) return; - patch[city.name] = { - ...previous, - market_scan: nextMarketScan, - }; - }); - - if (Object.keys(patch).length) { - touched = true; - Object.assign(detailMap, patch); - setDetailsByName((previous) => ({ - ...previous, - ...patch, - })); - } - } - - if (touched) { - setLastMarketUpdatedAt(new Date().toISOString()); - } - } finally { - marketRefreshInFlightRef.current = false; - } - }, [cities, detailsByName]); - - const loadAll = useCallback(async (force = false) => { - setError(null); - setRefreshing(true); - if (!citiesRef.current.length || force) { - setLoading(true); - } - if (force) { - initialMarketRequestsRef.current.clear(); - } - try { - const cityList = sortCities(await dashboardClient.getCities()); - setCities(cityList); - await fetchCityDetails(cityList, force); - setLastUpdatedAt(new Date().toISOString()); - } catch (loadError) { - if (!Object.keys(detailsByNameRef.current).length && !citiesRef.current.length) { - setError( - loadError instanceof Error - ? loadError.message - : locale === "en-US" - ? "Failed to load probability hub" - : "加载概率页失败", - ); - } else { - console.warn("probability hub refresh failed", loadError); - } - } finally { - setLoading(false); - setRefreshing(false); - } - }, [fetchCityDetails, locale]); - - const retryMissingCities = useCallback(async () => { - if (!cities.length) return; - const missingCities = cities.filter((city) => !detailsByName[city.name]); - if (!missingCities.length) return; - - try { - const fetched = await fetchCityDetails(missingCities, true); - setLastUpdatedAt(new Date().toISOString()); - const visibleMissing = missingCities.filter((city) => visibleCityNames.has(city.name)); - if (visibleMissing.length) { - await refreshMarketScans(fetched, visibleMissing); - } - } catch { - // keep silent; page-level retry should not override the main error banner - } - }, [cities, detailsByName, fetchCityDetails, refreshMarketScans, visibleCityNames]); - - useEffect(() => { - void loadAll(false); - }, [loadAll]); - - useEffect(() => { - const timer = window.setInterval(() => { - if (document.visibilityState === "hidden") return; - void retryMissingCities(); - }, 20_000); - - return () => window.clearInterval(timer); - }, [retryMissingCities]); - - useEffect(() => { - const timer = window.setInterval(() => { - if (document.visibilityState === "hidden") return; - void loadAll(false); - }, FULL_REFRESH_INTERVAL_MS); - - return () => window.clearInterval(timer); - }, [loadAll]); - - useEffect(() => { - const timer = window.setInterval(() => { - if (document.visibilityState === "hidden") return; - const visibleCitiesForRefresh = cities.filter((city) => visibleCityNames.has(city.name)); - if (!visibleCitiesForRefresh.length) return; - void refreshMarketScans(undefined, visibleCitiesForRefresh); - }, MARKET_REFRESH_INTERVAL_MS); - - return () => window.clearInterval(timer); - }, [cities, refreshMarketScans, visibleCityNames]); - - const loadedCount = Object.keys(detailsByName).length; - const cityCount = cities.length; - const readyCards = useMemo( - () => cities.filter((city) => detailsByName[city.name]).length, - [cities, detailsByName], - ); - const marketReadyCount = useMemo( - () => cities.filter((city) => hasMarket(detailsByName[city.name])).length, - [cities, detailsByName], - ); - const positiveEdgeCount = useMemo( - () => - cities.filter((city) => { - const edge = getPositiveEdge(detailsByName[city.name]); - return Number.isFinite(edge) && edge > 0; - }).length, - [cities, detailsByName], - ); - const visibleCities = useMemo(() => { - const filtered = cities.filter((city) => { - const detail = detailsByName[city.name]; - if (filterMode === "market") return hasMarket(detail); - if (filterMode === "high-risk") { - return String(detail?.risk?.level || city.risk_level || "").toLowerCase() === "high"; - } - return true; - }); - - return [...filtered].sort((a, b) => { - const detailA = detailsByName[a.name]; - const detailB = detailsByName[b.name]; - - if (sortMode === "edge") { - const edgeDelta = getPositiveEdge(detailB) - getPositiveEdge(detailA); - if (Number.isFinite(edgeDelta) && edgeDelta !== 0) return edgeDelta; - } - - if (sortMode === "probability") { - const probabilityDelta = getProbabilityPeak(detailB) - getProbabilityPeak(detailA); - if (Number.isFinite(probabilityDelta) && probabilityDelta !== 0) return probabilityDelta; - } - - if (sortMode === "updated") { - const updatedDelta = - new Date(detailB?.updated_at || 0).getTime() - - new Date(detailA?.updated_at || 0).getTime(); - if (updatedDelta !== 0) return updatedDelta; - } - - const riskDelta = - getRiskRank(detailA?.risk?.level || a.risk_level) - - getRiskRank(detailB?.risk?.level || b.risk_level); - if (riskDelta !== 0) return riskDelta; - - return String(a.display_name || a.name).localeCompare( - String(b.display_name || b.name), - ); - }); - }, [cities, detailsByName, filterMode, sortMode]); - - const visibleCitiesKey = useMemo( - () => visibleCities.map((city) => city.name).join("|"), - [visibleCities], - ); - - const setCardNode = useCallback((cityName: string, node: HTMLElement | null) => { - if (node) { - cardNodesRef.current.set(cityName, node); - } else { - cardNodesRef.current.delete(cityName); - } - }, []); - - useEffect(() => { - if (typeof IntersectionObserver === "undefined") { - setVisibleCityNames(new Set(visibleCities.slice(0, DETAIL_BATCH_SIZE).map((city) => city.name))); - return; - } - - const observer = new IntersectionObserver( - (entries) => { - setVisibleCityNames((previous) => { - const next = new Set(previous); - for (const entry of entries) { - const cityName = (entry.target as HTMLElement).dataset.cityName; - if (!cityName) continue; - if (entry.isIntersecting) { - next.add(cityName); - } else { - next.delete(cityName); - } - } - return next; - }); - }, - { - root: null, - rootMargin: "900px 0px", - threshold: 0.01, - }, - ); - - const namesInView = new Set(visibleCities.map((city) => city.name)); - for (const [cityName, node] of cardNodesRef.current.entries()) { - if (namesInView.has(cityName)) { - observer.observe(node); - } - } - - return () => observer.disconnect(); - }, [visibleCitiesKey, visibleCities]); - - useEffect(() => { - if (!visibleCityNames.size) return; - const citiesToWarm = cities.filter((city) => { - if (!visibleCityNames.has(city.name)) return false; - if (!detailsByName[city.name]) return false; - if (initialMarketRequestsRef.current.has(city.name)) return false; - return true; - }); - if (!citiesToWarm.length) return; - citiesToWarm.forEach((city) => initialMarketRequestsRef.current.add(city.name)); - void refreshMarketScans(undefined, citiesToWarm); - }, [cities, detailsByName, refreshMarketScans, visibleCityNames]); - - return ( -
- loadAll(true)} - refreshSpinning={refreshing} - /> -
-
-
-
- {locale === "en-US" - ? "52-city probability hub" - : "52 城市概率判断总览"} -
-
- {locale === "en-US" - ? "This page centralizes the intraday probability block for all monitored cities. The goal is fast scanning: see calibrated EMOS probabilities, market bucket alignment, and price comparison without opening each city modal one by one." - : "这里把 52 个监控城市的概率判断板块集中到一个页面,方便直接横向扫一遍,不用逐个打开城市弹窗。重点看 EMOS 校准概率、市场合约桶聚合,以及价格对比。"} -
-
- - {locale === "en-US" ? "Cities" : "城市数"} {cityCount || "--"} - - - {locale === "en-US" ? "Ready" : "已加载"} {readyCards} - - - {locale === "en-US" ? "Market" : "有市场"} {marketReadyCount} - - - {locale === "en-US" ? "Positive edge" : "有优势"} {positiveEdgeCount} - - - {locale === "en-US" ? "Updated" : "更新时间"}{" "} - - {lastUpdatedAt - ? new Date(lastUpdatedAt).toLocaleTimeString( - locale === "en-US" ? "en-US" : "zh-CN", - { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }, - ) - : "--"} - - - - {locale === "en-US" ? "Market tick" : "价格更新"}{" "} - - {lastMarketUpdatedAt - ? new Date(lastMarketUpdatedAt).toLocaleTimeString( - locale === "en-US" ? "en-US" : "zh-CN", - { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }, - ) - : "--"} - - -
-
- - {error ?
{error}
: null} -
- -
-
- - {locale === "en-US" ? "Filter" : "筛选"} - - - - -
-
- - {locale === "en-US" ? "Sort" : "排序"} - - - - - -
-
- {locale === "en-US" - ? `${visibleCities.length} cards in view` - : `当前显示 ${visibleCities.length} 张卡片`} -
-
- - {loading && loadedCount === 0 ? ( -
- {Array.from({ length: 6 }).map((_, index) => ( -
- ))} -
- ) : ( -
- {visibleCities.map((city) => { - const detail = detailsByName[city.name]; - if (!detail) { - return ( -
setCardNode(city.name, node)} - data-city-name={city.name} - className={styles.card} - > -
-
-
{city.display_name}
-
- {city.airport} ({city.icao}) -
-
-
-
- {locale === "en-US" - ? "Probability block is syncing..." - : "概率板块同步中..."} -
-
- ); - } - - return ( -
setCardNode(city.name, node)} - data-city-name={city.name} - className={styles.card} - > -
-
-
{detail.display_name}
-
- {detail.risk?.airport || city.airport} ({detail.risk?.icao || city.icao}) -
-
-
-
- - {locale === "en-US" ? "Current" : "当前"}{" "} - - {detail.current?.temp != null - ? `${detail.current.temp}${detail.temp_symbol}` - : "--"} - - - - {locale === "en-US" ? "Obs" : "观测"}{" "} - {detail.current?.obs_time || "--"} - - - {locale === "en-US" ? "Updated" : "更新"}{" "} - - {detail.updated_at - ? new Date(detail.updated_at).toLocaleTimeString( - locale === "en-US" ? "en-US" : "zh-CN", - { - hour: "2-digit", - minute: "2-digit", - }, - ) - : "--"} - - -
- {(() => { - const activeMarketScan = isActiveMarketScan(detail) - ? detail.market_scan - : null; - return ( - - ); - })()} -
- ); - })} -
- )} -
-
- ); -} - -export function ProbabilityHubPage() { - return ( - - - - - - ); -} diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts index 6b32ee44..fc0cf413 100644 --- a/frontend/lib/i18n.ts +++ b/frontend/lib/i18n.ts @@ -12,8 +12,6 @@ const MESSAGES: Record> = { "header.homeAria": "返回主地图主页", "header.docs": "文档", "header.docsAria": "打开产品文档中心", - "header.probabilityHub": "概率页", - "header.probabilityHubAria": "打开 52 城市概率汇总页", "header.info": "技术说明", "header.infoAria": "查看系统技术说明", "header.account": "账户", @@ -179,8 +177,6 @@ const MESSAGES: Record> = { "header.homeAria": "Return to the main map homepage", "header.docs": "Docs", "header.docsAria": "Open product documentation", - "header.probabilityHub": "Probabilities", - "header.probabilityHubAria": "Open the 52-city probability hub", "header.info": "Tech Notes", "header.infoAria": "Open system technical notes", "header.account": "Account",