feat: implement PolyWeather dashboard with map UI, data collection, analysis, and comprehensive documentation.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
CityDetail,
|
||||
CityListItem,
|
||||
MarketScan,
|
||||
CitySummary,
|
||||
HistoryPoint,
|
||||
} from "@/lib/dashboard-types";
|
||||
@@ -12,6 +13,7 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
|
||||
const pendingHistoryRequests = new Map<string, Promise<HistoryPoint[]>>();
|
||||
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
|
||||
const pendingMarketScanRequests = new Map<string, Promise<MarketScan | null>>();
|
||||
|
||||
type CityCacheMeta = {
|
||||
cachedAt: number;
|
||||
@@ -134,20 +136,75 @@ export const dashboardClient = {
|
||||
|
||||
async getCityDetail(cityName: string, options?: { force?: boolean }) {
|
||||
const force = options?.force ?? false;
|
||||
const requestKey = `${cityName}::${force ? "force" : "cached"}`;
|
||||
const existing = pendingCityDetailRequests.get(requestKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
if (!force) {
|
||||
const requestKey = `${cityName}::cached`;
|
||||
const existing = pendingCityDetailRequests.get(requestKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const request = fetchJson<CityDetail>(
|
||||
`/api/city/${normalizeCityName(cityName)}?force_refresh=false`,
|
||||
).finally(() => {
|
||||
pendingCityDetailRequests.delete(requestKey);
|
||||
});
|
||||
|
||||
pendingCityDetailRequests.set(requestKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
const request = fetchJson<CityDetail>(
|
||||
`/api/city/${normalizeCityName(cityName)}?force_refresh=${force}`,
|
||||
).finally(() => {
|
||||
pendingCityDetailRequests.delete(requestKey);
|
||||
const params = new URLSearchParams({
|
||||
force_refresh: "true",
|
||||
_ts: String(Date.now()),
|
||||
});
|
||||
return fetchJson<CityDetail>(
|
||||
`/api/city/${normalizeCityName(cityName)}?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
|
||||
pendingCityDetailRequests.set(requestKey, request);
|
||||
return request;
|
||||
async getCityMarketScan(
|
||||
cityName: string,
|
||||
options?: { force?: boolean; marketSlug?: string | null },
|
||||
) {
|
||||
const force = options?.force ?? false;
|
||||
const marketSlug = options?.marketSlug || null;
|
||||
if (!force) {
|
||||
const requestKey = `${cityName}::cached::${marketSlug || "-"}`;
|
||||
const existing = pendingMarketScanRequests.get(requestKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
force_refresh: "false",
|
||||
});
|
||||
if (marketSlug) {
|
||||
params.set("market_slug", marketSlug);
|
||||
}
|
||||
|
||||
const request = fetchJson<{ market_scan?: MarketScan }>(
|
||||
`/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`,
|
||||
)
|
||||
.then((data) => data.market_scan || null)
|
||||
.finally(() => {
|
||||
pendingMarketScanRequests.delete(requestKey);
|
||||
});
|
||||
|
||||
pendingMarketScanRequests.set(requestKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
force_refresh: "true",
|
||||
_ts: String(Date.now()),
|
||||
});
|
||||
if (marketSlug) {
|
||||
params.set("market_slug", marketSlug);
|
||||
}
|
||||
|
||||
return fetchJson<{ market_scan?: MarketScan }>(
|
||||
`/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`,
|
||||
).then((data) => data.market_scan || null);
|
||||
},
|
||||
|
||||
async getHistory(cityName: string) {
|
||||
|
||||
@@ -181,6 +181,71 @@ export interface DailyModelForecast {
|
||||
probabilities?: ProbabilityBucket[];
|
||||
}
|
||||
|
||||
export interface MarketToken {
|
||||
outcome?: string | null;
|
||||
token_id?: string | null;
|
||||
implied_probability?: number | null;
|
||||
buy_price?: number | null;
|
||||
sell_price?: number | null;
|
||||
midpoint?: number | null;
|
||||
last_trade_price?: number | null;
|
||||
}
|
||||
|
||||
export interface MarketPrimary {
|
||||
id?: string | null;
|
||||
question?: string | null;
|
||||
slug?: string | null;
|
||||
condition_id?: string | null;
|
||||
end_date?: string | null;
|
||||
active?: boolean;
|
||||
closed?: boolean;
|
||||
liquidity?: number | null;
|
||||
volume?: number | null;
|
||||
}
|
||||
|
||||
export interface MarketTopBucket {
|
||||
label?: string | null;
|
||||
value?: number | null;
|
||||
temp?: number | null;
|
||||
probability?: number | null;
|
||||
market_price?: number | null;
|
||||
yes_buy?: number | null;
|
||||
yes_sell?: number | null;
|
||||
no_buy?: number | null;
|
||||
no_sell?: number | null;
|
||||
slug?: string | null;
|
||||
question?: string | null;
|
||||
is_primary?: boolean;
|
||||
}
|
||||
|
||||
export interface MarketScan {
|
||||
available?: boolean;
|
||||
reason?: string | null;
|
||||
primary_market?: MarketPrimary | null;
|
||||
selected_date?: string | null;
|
||||
selected_condition_id?: string | null;
|
||||
selected_slug?: string | null;
|
||||
temperature_bucket?: ProbabilityBucket | null;
|
||||
model_probability?: number | null;
|
||||
market_price?: number | null;
|
||||
edge_percent?: number | null;
|
||||
signal_label?: string | null;
|
||||
confidence?: string | null;
|
||||
yes_token?: MarketToken | null;
|
||||
no_token?: MarketToken | null;
|
||||
yes_buy?: number | null;
|
||||
yes_sell?: number | null;
|
||||
no_buy?: number | null;
|
||||
no_sell?: number | null;
|
||||
last_trade_price?: number | null;
|
||||
liquidity?: number | null;
|
||||
volume?: number | null;
|
||||
sparkline?: number[];
|
||||
top_buckets?: MarketTopBucket[] | null;
|
||||
recent_trades?: unknown[];
|
||||
websocket?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AiAnalysisStructured {
|
||||
summary?: string | null;
|
||||
text?: string | null;
|
||||
@@ -227,6 +292,7 @@ export interface CityDetail {
|
||||
updated_at?: string;
|
||||
multi_model_daily?: Record<string, DailyModelForecast>;
|
||||
source_forecasts?: SourceForecasts;
|
||||
market_scan?: MarketScan;
|
||||
}
|
||||
|
||||
export interface HistoryPoint {
|
||||
@@ -241,6 +307,7 @@ export interface LoadingState {
|
||||
cityDetail: boolean;
|
||||
refresh: boolean;
|
||||
history: boolean;
|
||||
marketScan?: boolean;
|
||||
}
|
||||
|
||||
export interface HistoryState {
|
||||
|
||||
+12
-8
@@ -71,7 +71,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
"future.score": "趋势评分",
|
||||
"future.todayTempTrend": "今日温度走势",
|
||||
"future.targetTempTrend": "目标日小时走势",
|
||||
"future.probability": "结算概率分布",
|
||||
"future.probability": "模型结算概率分布",
|
||||
"future.models": "多模型预报",
|
||||
"future.structureToday": "今日日内结构信号",
|
||||
"future.structureDate": "未来 6-48 小时趋势",
|
||||
@@ -91,7 +91,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
|
||||
"section.todayTempTrend": "今日温度走势",
|
||||
"section.chartEmpty": "暂无小时级数据",
|
||||
"section.probability": "结算概率分布",
|
||||
"section.probability": "模型结算概率分布",
|
||||
"section.mu": "动态分布中心 μ = {value}{unit}",
|
||||
"section.noProb": "暂无概率数据",
|
||||
"section.models": "多模型预报",
|
||||
@@ -133,7 +133,8 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
"No scenery image matched. You can still review station and observation profile below.",
|
||||
"detail.profile": "City Profile",
|
||||
"detail.todayMiniTrend": "Today's Intraday Trend (Compact)",
|
||||
"detail.chartLegendEmpty": "No hourly observations or forecast curve available.",
|
||||
"detail.chartLegendEmpty":
|
||||
"No hourly observations or forecast curve available.",
|
||||
|
||||
"forecast.title": "Multi-day Forecast",
|
||||
"forecast.empty": "No multi-day forecast available",
|
||||
@@ -171,7 +172,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
"future.score": "Trend Score",
|
||||
"future.todayTempTrend": "Today's Temperature Trend",
|
||||
"future.targetTempTrend": "Target-day Hourly Trend",
|
||||
"future.probability": "Settlement Probability Distribution",
|
||||
"future.probability": "Model Settlement Probabilities",
|
||||
"future.models": "Multi-model Forecast",
|
||||
"future.structureToday": "Intraday Structural Signal",
|
||||
"future.structureDate": "6-48h Structural Trend",
|
||||
@@ -179,11 +180,13 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
"future.confidence": "Confidence",
|
||||
"future.maxPrecip": "Max Precip Probability",
|
||||
"future.ai": "AI Deep Analysis",
|
||||
"future.noAi": "No AI analysis available. Structured meteorological and model data are used as baseline.",
|
||||
"future.noAi":
|
||||
"No AI analysis available. Structured meteorological and model data are used as baseline.",
|
||||
"future.weatherGov": "weather.gov text",
|
||||
"future.risk": "Settlement & Deviation Risk",
|
||||
"future.climate": "What Mainly Drives Local Climate",
|
||||
"future.chartLegendEmpty": "No METAR bulletin or hourly observations available",
|
||||
"future.chartLegendEmpty":
|
||||
"No METAR bulletin or hourly observations available",
|
||||
|
||||
"confidence.high": "High",
|
||||
"confidence.medium": "Medium",
|
||||
@@ -191,13 +194,14 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
||||
|
||||
"section.todayTempTrend": "Today's Temperature Trend",
|
||||
"section.chartEmpty": "No hourly data available",
|
||||
"section.probability": "Settlement Probability Distribution",
|
||||
"section.probability": "Model Settlement Probabilities",
|
||||
"section.mu": "Dynamic center μ = {value}{unit}",
|
||||
"section.noProb": "No probability data available",
|
||||
"section.models": "Multi-model Forecast",
|
||||
"section.noModels": "No multi-model forecast available",
|
||||
"section.ai": "AI Deep Analysis",
|
||||
"section.aiEmpty": "No AI analysis available. Structured meteorological and model data are currently used.",
|
||||
"section.aiEmpty":
|
||||
"No AI analysis available. Structured meteorological and model data are currently used.",
|
||||
"section.risk": "Data Deviation Risk",
|
||||
"section.noRiskProfile": "No risk profile available",
|
||||
"section.airport": "Airport",
|
||||
|
||||
@@ -292,6 +292,20 @@ export interface MarketScan {
|
||||
liquidity: number | null;
|
||||
volume: number | null;
|
||||
sparkline: number[];
|
||||
top_buckets?: Array<{
|
||||
label?: string | null;
|
||||
value?: number | null;
|
||||
temp?: number | null;
|
||||
probability?: number | null;
|
||||
market_price?: number | null;
|
||||
yes_buy?: number | null;
|
||||
yes_sell?: number | null;
|
||||
no_buy?: number | null;
|
||||
no_sell?: number | null;
|
||||
slug?: string | null;
|
||||
question?: string | null;
|
||||
is_primary?: boolean;
|
||||
}>;
|
||||
recent_trades: Trade[];
|
||||
websocket: any;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user