Remove probability hub page
This commit is contained in:
@@ -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 <ProbabilityHubPage />;
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
RotateCw,
|
RotateCw,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
BarChart3,
|
|
||||||
House,
|
House,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
@@ -44,8 +43,6 @@ export function HeaderBar({
|
|||||||
const docsActive = pathname?.startsWith("/docs");
|
const docsActive = pathname?.startsWith("/docs");
|
||||||
const homeHref = "/";
|
const homeHref = "/";
|
||||||
const homeActive = pathname === "/";
|
const homeActive = pathname === "/";
|
||||||
const probabilityHubHref = "/probabilities";
|
|
||||||
const probabilityHubActive = pathname?.startsWith("/probabilities");
|
|
||||||
const trialPromoLabel =
|
const trialPromoLabel =
|
||||||
locale === "en-US"
|
locale === "en-US"
|
||||||
? "New users get 3-day Pro trial"
|
? "New users get 3-day Pro trial"
|
||||||
@@ -154,16 +151,6 @@ export function HeaderBar({
|
|||||||
{t("header.docs")}
|
{t("header.docs")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
|
||||||
href={probabilityHubHref}
|
|
||||||
className={clsx("info-btn", probabilityHubActive && "active")}
|
|
||||||
title={t("header.probabilityHubAria")}
|
|
||||||
aria-label={t("header.probabilityHubAria")}
|
|
||||||
>
|
|
||||||
<BarChart3 size={14} strokeWidth={2} />
|
|
||||||
{t("header.probabilityHub")}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/account"
|
href="/account"
|
||||||
className="trial-promo-badge"
|
className="trial-promo-badge"
|
||||||
|
|||||||
@@ -1,261 +0,0 @@
|
|||||||
.pageRoot {
|
|
||||||
min-height: 100vh;
|
|
||||||
width: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageBody {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
padding: 72px 20px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroCard {
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroTitle {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--text-primary);
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroText {
|
|
||||||
margin-top: 8px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroMeta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroPill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-height: 30px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(34, 211, 238, 0.18);
|
|
||||||
background: rgba(8, 47, 73, 0.24);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroPill strong {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorCard {
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.24);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
background: rgba(127, 29, 29, 0.16);
|
|
||||||
color: #fecaca;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbarGroup {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbarLabel {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbarButton {
|
|
||||||
min-height: 30px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
border-color 0.18s ease,
|
|
||||||
background 0.18s ease,
|
|
||||||
color 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbarButton:hover {
|
|
||||||
border-color: rgba(34, 211, 238, 0.28);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbarButton.active {
|
|
||||||
border-color: rgba(34, 211, 238, 0.42);
|
|
||||||
background: rgba(8, 47, 73, 0.28);
|
|
||||||
color: #e0fbff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbarSummary {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 14px;
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardHead {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardTitleBlock {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardTitle {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--text-primary);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardSubTitle {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardMeta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metaChip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metaChip + .metaChip::before {
|
|
||||||
content: "";
|
|
||||||
display: inline-block;
|
|
||||||
width: 1px;
|
|
||||||
height: 10px;
|
|
||||||
margin-right: 6px;
|
|
||||||
background: rgba(148, 163, 184, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metaChip strong {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingCard {
|
|
||||||
height: 280px;
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
background:
|
|
||||||
linear-gradient(
|
|
||||||
90deg,
|
|
||||||
rgba(255, 255, 255, 0.02) 0%,
|
|
||||||
rgba(255, 255, 255, 0.05) 50%,
|
|
||||||
rgba(255, 255, 255, 0.02) 100%
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: probabilityHubShimmer 1.6s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes probabilityHubShimmer {
|
|
||||||
0% {
|
|
||||||
background-position: 200% 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.grid,
|
|
||||||
.loadingGrid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.pageBody {
|
|
||||||
padding: 68px 12px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroCard,
|
|
||||||
.card {
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroTitle {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,674 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import dashboardStyles from "@/components/dashboard/Dashboard.module.css";
|
|
||||||
import { HeaderBar } from "@/components/dashboard/HeaderBar";
|
|
||||||
import { ProbabilityDistribution } from "@/components/dashboard/PanelSections";
|
|
||||||
import { dashboardClient } from "@/lib/dashboard-client";
|
|
||||||
import type { CityDetail, CityListItem } from "@/lib/dashboard-types";
|
|
||||||
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
|
||||||
import { DashboardStoreProvider } from "@/hooks/useDashboardStore";
|
|
||||||
import styles from "./ProbabilityHubPage.module.css";
|
|
||||||
|
|
||||||
const DETAIL_BATCH_SIZE = 6;
|
|
||||||
const FULL_REFRESH_INTERVAL_MS = 60_000;
|
|
||||||
const MARKET_REFRESH_INTERVAL_MS = 20_000;
|
|
||||||
|
|
||||||
type FilterMode = "all" | "market" | "high-risk";
|
|
||||||
type SortMode = "risk" | "edge" | "probability" | "updated";
|
|
||||||
|
|
||||||
function sortCities(cities: CityListItem[]) {
|
|
||||||
const riskOrder: Record<string, number> = {
|
|
||||||
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<string, number> = {
|
|
||||||
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<string, unknown>)
|
|
||||||
| 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<CityListItem[]>([]);
|
|
||||||
const [detailsByName, setDetailsByName] = useState<Record<string, CityDetail>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
|
|
||||||
const [lastMarketUpdatedAt, setLastMarketUpdatedAt] = useState<string | null>(null);
|
|
||||||
const [filterMode, setFilterMode] = useState<FilterMode>("all");
|
|
||||||
const [sortMode, setSortMode] = useState<SortMode>("risk");
|
|
||||||
const [visibleCityNames, setVisibleCityNames] = useState<Set<string>>(new Set());
|
|
||||||
const cardNodesRef = useRef(new Map<string, HTMLElement>());
|
|
||||||
const initialMarketRequestsRef = useRef(new Set<string>());
|
|
||||||
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<string, CityDetail> = {};
|
|
||||||
|
|
||||||
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<string, CityDetail> = {};
|
|
||||||
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<string, CityDetail>,
|
|
||||||
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<string, CityDetail> = {};
|
|
||||||
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 (
|
|
||||||
<div className={clsx(dashboardStyles.root, styles.pageRoot)}>
|
|
||||||
<HeaderBar
|
|
||||||
refreshAction={() => loadAll(true)}
|
|
||||||
refreshSpinning={refreshing}
|
|
||||||
/>
|
|
||||||
<main className={styles.pageBody}>
|
|
||||||
<section className={styles.hero}>
|
|
||||||
<div className={styles.heroCard}>
|
|
||||||
<div className={styles.heroTitle}>
|
|
||||||
{locale === "en-US"
|
|
||||||
? "52-city probability hub"
|
|
||||||
: "52 城市概率判断总览"}
|
|
||||||
</div>
|
|
||||||
<div className={styles.heroText}>
|
|
||||||
{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 校准概率、市场合约桶聚合,以及价格对比。"}
|
|
||||||
</div>
|
|
||||||
<div className={styles.heroMeta}>
|
|
||||||
<span className={styles.heroPill}>
|
|
||||||
{locale === "en-US" ? "Cities" : "城市数"} <strong>{cityCount || "--"}</strong>
|
|
||||||
</span>
|
|
||||||
<span className={styles.heroPill}>
|
|
||||||
{locale === "en-US" ? "Ready" : "已加载"} <strong>{readyCards}</strong>
|
|
||||||
</span>
|
|
||||||
<span className={styles.heroPill}>
|
|
||||||
{locale === "en-US" ? "Market" : "有市场"} <strong>{marketReadyCount}</strong>
|
|
||||||
</span>
|
|
||||||
<span className={styles.heroPill}>
|
|
||||||
{locale === "en-US" ? "Positive edge" : "有优势"} <strong>{positiveEdgeCount}</strong>
|
|
||||||
</span>
|
|
||||||
<span className={styles.heroPill}>
|
|
||||||
{locale === "en-US" ? "Updated" : "更新时间"}{" "}
|
|
||||||
<strong>
|
|
||||||
{lastUpdatedAt
|
|
||||||
? new Date(lastUpdatedAt).toLocaleTimeString(
|
|
||||||
locale === "en-US" ? "en-US" : "zh-CN",
|
|
||||||
{
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: "--"}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<span className={styles.heroPill}>
|
|
||||||
{locale === "en-US" ? "Market tick" : "价格更新"}{" "}
|
|
||||||
<strong>
|
|
||||||
{lastMarketUpdatedAt
|
|
||||||
? new Date(lastMarketUpdatedAt).toLocaleTimeString(
|
|
||||||
locale === "en-US" ? "en-US" : "zh-CN",
|
|
||||||
{
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: "--"}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? <div className={styles.errorCard}>{error}</div> : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className={styles.toolbar}>
|
|
||||||
<div className={styles.toolbarGroup}>
|
|
||||||
<span className={styles.toolbarLabel}>
|
|
||||||
{locale === "en-US" ? "Filter" : "筛选"}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={clsx(styles.toolbarButton, filterMode === "all" && styles.active)}
|
|
||||||
onClick={() => setFilterMode("all")}
|
|
||||||
>
|
|
||||||
{locale === "en-US" ? "All" : "全部"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={clsx(styles.toolbarButton, filterMode === "market" && styles.active)}
|
|
||||||
onClick={() => setFilterMode("market")}
|
|
||||||
>
|
|
||||||
{locale === "en-US" ? "With market" : "仅看有市场"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={clsx(styles.toolbarButton, filterMode === "high-risk" && styles.active)}
|
|
||||||
onClick={() => setFilterMode("high-risk")}
|
|
||||||
>
|
|
||||||
{locale === "en-US" ? "High risk" : "仅看高风险"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className={styles.toolbarGroup}>
|
|
||||||
<span className={styles.toolbarLabel}>
|
|
||||||
{locale === "en-US" ? "Sort" : "排序"}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={clsx(styles.toolbarButton, sortMode === "risk" && styles.active)}
|
|
||||||
onClick={() => setSortMode("risk")}
|
|
||||||
>
|
|
||||||
{locale === "en-US" ? "Risk" : "风险"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={clsx(styles.toolbarButton, sortMode === "edge" && styles.active)}
|
|
||||||
onClick={() => setSortMode("edge")}
|
|
||||||
>
|
|
||||||
{locale === "en-US" ? "Edge" : "优势"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={clsx(styles.toolbarButton, sortMode === "probability" && styles.active)}
|
|
||||||
onClick={() => setSortMode("probability")}
|
|
||||||
>
|
|
||||||
{locale === "en-US" ? "Probability" : "概率"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={clsx(styles.toolbarButton, sortMode === "updated" && styles.active)}
|
|
||||||
onClick={() => setSortMode("updated")}
|
|
||||||
>
|
|
||||||
{locale === "en-US" ? "Updated" : "更新时间"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className={styles.toolbarSummary}>
|
|
||||||
{locale === "en-US"
|
|
||||||
? `${visibleCities.length} cards in view`
|
|
||||||
: `当前显示 ${visibleCities.length} 张卡片`}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{loading && loadedCount === 0 ? (
|
|
||||||
<div className={styles.loadingGrid}>
|
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
|
||||||
<div key={index} className={styles.loadingCard} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.grid}>
|
|
||||||
{visibleCities.map((city) => {
|
|
||||||
const detail = detailsByName[city.name];
|
|
||||||
if (!detail) {
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
key={city.name}
|
|
||||||
ref={(node) => setCardNode(city.name, node)}
|
|
||||||
data-city-name={city.name}
|
|
||||||
className={styles.card}
|
|
||||||
>
|
|
||||||
<div className={styles.cardHead}>
|
|
||||||
<div className={styles.cardTitleBlock}>
|
|
||||||
<div className={styles.cardTitle}>{city.display_name}</div>
|
|
||||||
<div className={styles.cardSubTitle}>
|
|
||||||
{city.airport} ({city.icao})
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.cardSubTitle}>
|
|
||||||
{locale === "en-US"
|
|
||||||
? "Probability block is syncing..."
|
|
||||||
: "概率板块同步中..."}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
key={city.name}
|
|
||||||
ref={(node) => setCardNode(city.name, node)}
|
|
||||||
data-city-name={city.name}
|
|
||||||
className={styles.card}
|
|
||||||
>
|
|
||||||
<div className={styles.cardHead}>
|
|
||||||
<div className={styles.cardTitleBlock}>
|
|
||||||
<div className={styles.cardTitle}>{detail.display_name}</div>
|
|
||||||
<div className={styles.cardSubTitle}>
|
|
||||||
{detail.risk?.airport || city.airport} ({detail.risk?.icao || city.icao})
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.cardMeta}>
|
|
||||||
<span className={styles.metaChip}>
|
|
||||||
{locale === "en-US" ? "Current" : "当前"}{" "}
|
|
||||||
<strong>
|
|
||||||
{detail.current?.temp != null
|
|
||||||
? `${detail.current.temp}${detail.temp_symbol}`
|
|
||||||
: "--"}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<span className={styles.metaChip}>
|
|
||||||
{locale === "en-US" ? "Obs" : "观测"}{" "}
|
|
||||||
<strong>{detail.current?.obs_time || "--"}</strong>
|
|
||||||
</span>
|
|
||||||
<span className={styles.metaChip}>
|
|
||||||
{locale === "en-US" ? "Updated" : "更新"}{" "}
|
|
||||||
<strong>
|
|
||||||
{detail.updated_at
|
|
||||||
? new Date(detail.updated_at).toLocaleTimeString(
|
|
||||||
locale === "en-US" ? "en-US" : "zh-CN",
|
|
||||||
{
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: "--"}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const activeMarketScan = isActiveMarketScan(detail)
|
|
||||||
? detail.market_scan
|
|
||||||
: null;
|
|
||||||
return (
|
|
||||||
<ProbabilityDistribution
|
|
||||||
detail={detail}
|
|
||||||
hideTitle
|
|
||||||
marketScan={activeMarketScan}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProbabilityHubPage() {
|
|
||||||
return (
|
|
||||||
<I18nProvider>
|
|
||||||
<DashboardStoreProvider>
|
|
||||||
<ProbabilityHubScreen />
|
|
||||||
</DashboardStoreProvider>
|
|
||||||
</I18nProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,8 +12,6 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
|||||||
"header.homeAria": "返回主地图主页",
|
"header.homeAria": "返回主地图主页",
|
||||||
"header.docs": "文档",
|
"header.docs": "文档",
|
||||||
"header.docsAria": "打开产品文档中心",
|
"header.docsAria": "打开产品文档中心",
|
||||||
"header.probabilityHub": "概率页",
|
|
||||||
"header.probabilityHubAria": "打开 52 城市概率汇总页",
|
|
||||||
"header.info": "技术说明",
|
"header.info": "技术说明",
|
||||||
"header.infoAria": "查看系统技术说明",
|
"header.infoAria": "查看系统技术说明",
|
||||||
"header.account": "账户",
|
"header.account": "账户",
|
||||||
@@ -179,8 +177,6 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
|||||||
"header.homeAria": "Return to the main map homepage",
|
"header.homeAria": "Return to the main map homepage",
|
||||||
"header.docs": "Docs",
|
"header.docs": "Docs",
|
||||||
"header.docsAria": "Open product documentation",
|
"header.docsAria": "Open product documentation",
|
||||||
"header.probabilityHub": "Probabilities",
|
|
||||||
"header.probabilityHubAria": "Open the 52-city probability hub",
|
|
||||||
"header.info": "Tech Notes",
|
"header.info": "Tech Notes",
|
||||||
"header.infoAria": "Open system technical notes",
|
"header.infoAria": "Open system technical notes",
|
||||||
"header.account": "Account",
|
"header.account": "Account",
|
||||||
|
|||||||
Reference in New Issue
Block a user