Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 020c62676e | |||
| 243bf6c33b | |||
| 283a935f24 | |||
| d162b91ed9 | |||
| d45ee8a981 | |||
| d184bc323c | |||
| b53f1f74bd | |||
| 0417387c0b | |||
| 1655a026e8 | |||
| 2da6e6829f | |||
| ae40f70cac | |||
| d2107cbcbe |
@@ -1,22 +1,22 @@
|
||||
# 🌡️ PolyWeather Pro
|
||||
# 🌡️ PolyWeather Pro
|
||||
|
||||
> **Professional Weather Intelligence System** —— Specialized in edge data collection, DEB smart blending, and real-time decision alerts.
|
||||
> **Professional Weather Intelligence System** — Specialized in edge data collection, DEB smart blending, and real-time decision alerts.
|
||||
|
||||
---
|
||||
|
||||
## 💎 Project Vision
|
||||
|
||||
PolyWeather is a specialized intelligence system built for **Polymarket** high-stakes participants. We don't just provide weather forecasts; we aggregate data from top-tier global meteorological sources, apply our proprietary **DEB (Dynamic Error Balancing)** algorithm, and deliver **market-shifting alerts** at critical decision nodes.
|
||||
PolyWeather is a specialized intelligence system built for **Polymarket** high-stakes participants. We aggregate top-tier meteorological sources, apply proprietary **DEB (Dynamic Error Balancing)** logic, and surface **actionable shift signals** at critical decision windows.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Production Architecture
|
||||
|
||||
This project utilizes a production-grade decoupled architecture for high availability:
|
||||
This project uses a decoupled production setup for reliability and iteration speed:
|
||||
|
||||
- **Frontend**: A **Next.js** interactive dashboard deployed on **Vercel**.
|
||||
- **Backend API**: A **FastAPI** service running on a VPS, providing low-latency data access.
|
||||
- **Bot & Alert Heartbeat**: A **Telegram Bot** running on a VPS, executing minute-level global scans and push notifications.
|
||||
- **Frontend**: A **Next.js** dashboard on **Vercel** with React component rendering.
|
||||
- **Backend API**: A **FastAPI** service on VPS for low-latency weather aggregation and analysis.
|
||||
- **Bot & Alert Heartbeat**: A **Telegram Bot** on VPS for minute-level scanning and push alerts.
|
||||
|
||||
🔗 **Official Visit**: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
|
||||
@@ -27,13 +27,13 @@ This project utilizes a production-grade decoupled architecture for high availab
|
||||
<p align="center">
|
||||
<img src="docs/images/demo_ankara.png" alt="PolyWeather Demo - Ankara Live Analysis" width="450">
|
||||
<br>
|
||||
<em>📊 <b>Deep Query View</b>: DEB Blended Forecast + Settlement Probability + Groq AI Expert Advice</em>
|
||||
<em>📊 <b>Deep Query View</b>: DEB blended forecast + settlement probability + AI analysis context</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/images/demo_map.png" alt="PolyWeather Web Map" width="850">
|
||||
<br>
|
||||
<em>🗺️ <b>Omni-Dashboard</b>: Real-time global heatmaps + array-style data visualization</em>
|
||||
<em>🗺️ <b>Omni-Dashboard</b>: global station markers + nearby station context + right-side city intelligence panel</em>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -41,18 +41,19 @@ This project utilizes a production-grade decoupled architecture for high availab
|
||||
## 🚀 Core Features
|
||||
|
||||
- **📡 Full-Spectrum Collection**
|
||||
- **Major Models**: Real-time sync for ECMWF, GFS, ICON, GEM, and JMA high temperatures.
|
||||
- **Observed Data**: Global airport METAR reports + official Turkish MGM station-level data.
|
||||
- **Centralized Correction**: Integrated `17130` (Center) official data specifically for Ankara.
|
||||
- **Major Models**: ECMWF, GFS, ICON, GEM, JMA, Open-Meteo, and city-level daily/hourly guidance.
|
||||
- **Observed Data**: Aviation Weather / METAR as the primary observation source, plus Turkish MGM coverage for Ankara.
|
||||
- **City Specialization**: `17130` (`Ankara (Bölge/Center)`) remains the Ankara lead station without replacing LTAC settlement observation.
|
||||
- **⚖️ DEB Smart Blending**
|
||||
- Dynamic weighting of forecasts based on recent 7-day historical performance.
|
||||
- Dynamic weighting based on city-level performance and current model spread.
|
||||
- **🧩 React Dashboard Runtime**
|
||||
- Typed store + typed API client + Leaflet/Chart.js lifecycle wrappers.
|
||||
- City click workflow: map focus + right panel open + nearby stations render.
|
||||
- Today analysis workflow: open modal + freeze map motion.
|
||||
- **🔔 Alert Engine**
|
||||
- **Momentum Spike**: Captures rapid temperature changes within 30 minutes.
|
||||
- **Forecast Breakthrough**: Fires when observations exceed all model predictions plus a safety margin.
|
||||
- **Advection Monitoring**: Simulates warm/cold advection based on lead stations and wind currents.
|
||||
- **🛡️ Smart Suppression**
|
||||
- **Peak Protection**: Automatically switches to snapshot mode when the daily high has likely passed.
|
||||
- **Cooldown Management**: Global and city-level cooldowns to prevent notification fatigue.
|
||||
- **Momentum Spike**: Captures rapid short-window temperature slope changes.
|
||||
- **Forecast Breakthrough**: Fires when observations break model envelopes plus margin.
|
||||
- **Advection Monitoring**: Tracks warm/cold advection using lead-station behavior and wind direction.
|
||||
|
||||
---
|
||||
|
||||
@@ -67,24 +68,40 @@ This project utilizes a production-grade decoupled architecture for high availab
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Current Data Logic
|
||||
|
||||
- **Primary observation source**: Aviation Weather / METAR
|
||||
- **Ankara enhancement**:
|
||||
- Settlement observation: `LTAC / Esenboğa`
|
||||
- Official lead station: `Ankara (Bölge/Center)` / `17130`
|
||||
- Nearby station layer: Turkish MGM network (Ankara-specific preferred station ordering)
|
||||
- **Other cities nearby layer**:
|
||||
- Production currently uses Aviation Weather METAR clusters
|
||||
- U.S. cities may later receive Mesonet augmentation while METAR stays baseline
|
||||
- **Frontend request optimization**:
|
||||
- Initial map temperatures preload via `/api/city/{name}/summary`
|
||||
- City detail cache TTL = 5 minutes, revision probe avoids unnecessary refetch
|
||||
- Map movements, panel toggles, and modal open/close do not trigger redundant requests
|
||||
- Manual refresh always bypasses cache (`force_refresh=true`)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ System Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Client / Terminals"
|
||||
Web[Next.js Web App]
|
||||
Web[Next.js React Web App]
|
||||
TG[Telegram Client]
|
||||
end
|
||||
|
||||
subgraph "Edge Deployment (Vercel)"
|
||||
Web -.-> |Auth| Supa[(Supabase Auth/DB)]
|
||||
Web --> |API| Fast[FastAPI API]
|
||||
Web --> |BFF Routes| Fast[FastAPI API]
|
||||
end
|
||||
|
||||
subgraph "Core Hub (VPS)"
|
||||
Fast --- |Shared Logic| Worker[Alert Engine / Worker]
|
||||
Bot[Telegram Bot] --- |Shared Logic| Worker
|
||||
Worker --> |Cache/Sub| Supa
|
||||
end
|
||||
|
||||
subgraph "External Sources"
|
||||
@@ -105,7 +122,7 @@ graph TD
|
||||
### 1. Backend / Bot (VPS)
|
||||
|
||||
```bash
|
||||
# Pull Source
|
||||
# Pull source
|
||||
git pull
|
||||
|
||||
# Environment
|
||||
@@ -117,7 +134,7 @@ docker-compose up -d --build
|
||||
|
||||
### 2. Frontend (Vercel)
|
||||
|
||||
Associate the `frontend` directory as the project root on Vercel for automatic CI/CD.
|
||||
Set `frontend` as the Vercel root directory for automatic CI/CD.
|
||||
|
||||
---
|
||||
|
||||
@@ -127,21 +144,25 @@ Associate the `frontend` directory as the project root on Vercel for automatic C
|
||||
| :-------- | :-------------------------------------- | :------------- |
|
||||
| `/city` | Query real-time analysis for a city | `/city ankara` |
|
||||
| `/deb` | View historical accuracy of DEB model | `/deb london` |
|
||||
| `/points` | View your activity points & leaderboard | `/points` |
|
||||
| `/top` | View activity leaderboard | `/top` |
|
||||
| `/help` | Get detailed instructions | `/help` |
|
||||
|
||||
---
|
||||
|
||||
> [!NOTE]
|
||||
> **Commercialization**: This project currently offers **Web Dashboard ($5/mo)** and **Telegram Signal Channel ($1/mo)** subscriptions.
|
||||
> Point-earning via group participation is active and points can be redeemed for access.
|
||||
> **Commercialization**: Current plans keep **Web Dashboard ($5/mo)** and **Telegram Signal Channel ($1/mo)** as the core entry offers.
|
||||
> User entitlement and payment automation are tracked in `docs/COMMERCIALIZATION.md`.
|
||||
|
||||
> [!NOTE]
|
||||
> **Frontend Model**: Production rendering is now fully handled by React components under `frontend/components/dashboard` and hooks under `frontend/hooks`.
|
||||
> Legacy static files are retained for reference, but no longer act as the main runtime path.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
**📅 Last Updated**: 2026-03-08
|
||||
**🚀 Status**: v1.0 Stable - Professional Quant UI Locked
|
||||
**📅 Last Updated**: 2026-03-09
|
||||
**🚀 Status**: v1.1 Stable - React Dashboard Runtime in Production
|
||||
|
||||
> [!TIP]
|
||||
> **Production Note**: The current dashboard utilizes the high-density "Professional Quant" UI (v1.0-legacy) which integrates real-time METAR/MGM data, DEB ensemble blending, and multi-model probability distribution in a single high-performance view.
|
||||
> **Production Note**: The UI layout and visual contract remain unchanged while data flow, map lifecycle, and modal interaction are now managed by typed React modules.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 🌡️ PolyWeather Pro
|
||||
# 🌡️ PolyWeather Pro
|
||||
|
||||
> **专业级博弈情报系统** —— 专注边缘气象数据采集、DEB 智能融合与实时决策预警。
|
||||
|
||||
@@ -6,17 +6,17 @@
|
||||
|
||||
## 💎 项目愿景
|
||||
|
||||
PolyWeather 是一套专为 **Polymarket** 深度博弈者设计的实时情报系统。我们不只是提供天气预报,而是通过聚合全球顶级气象源、应用自研的 **DEB (Dynamic Error Balancing)** 算法,并在关键时间节点提供**具有博弈预测价值**的异动预警。
|
||||
PolyWeather 是一套专为 **Polymarket** 深度博弈者设计的实时情报系统。我们不只提供天气预报,而是通过聚合全球气象源、应用自研 **DEB (Dynamic Error Balancing)** 算法,并在关键时间节点输出**可执行的异动信号**。
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 生产架构
|
||||
|
||||
本项目采用生产级解耦架构,确保高可用与实时性:
|
||||
本项目采用生产级解耦架构,确保高可用与迭代效率:
|
||||
|
||||
- **前端**:部署在 **Vercel** 上的 **Next.js** 交互式仪表盘。
|
||||
- **后端 API**:运行在 VPS 上的 **FastAPI**,提供低延迟数据服务。
|
||||
- **机器人与预警心跳**:运行在 VPS 上的 **Telegram Bot**,执行每分钟级的全球扫描与推送。
|
||||
- **前端**:部署在 **Vercel** 上的 **Next.js + React 组件化仪表盘**。
|
||||
- **后端 API**:运行在 VPS 上的 **FastAPI**,负责多源聚合与分析计算。
|
||||
- **机器人与预警心跳**:运行在 VPS 上的 **Telegram Bot**,执行分钟级扫描与推送。
|
||||
|
||||
🔗 **官方访问地址**:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
|
||||
@@ -27,13 +27,13 @@ PolyWeather 是一套专为 **Polymarket** 深度博弈者设计的实时情报
|
||||
<p align="center">
|
||||
<img src="docs/images/demo_ankara.png" alt="PolyWeather 效果展示 - 安卡拉实时分析" width="450">
|
||||
<br>
|
||||
<em>📊 <b>深度查询效果</b>:DEB 融合预测 + 结算概率 + Groq AI 专家建议</em>
|
||||
<em>📊 <b>深度查询效果</b>:DEB 融合预测 + 结算概率 + AI 分析上下文</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/images/demo_map.png" alt="PolyWeather Web Map" width="850">
|
||||
<br>
|
||||
<em>🗺️ <b>全景仪表盘</b>:全球站点实时热力场 + 阵列式数据展示</em>
|
||||
<em>🗺️ <b>全景仪表盘</b>:全球站点标记 + 周边站点联动 + 右侧城市详情卡片</em>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -41,18 +41,19 @@ PolyWeather 是一套专为 **Polymarket** 深度博弈者设计的实时情报
|
||||
## 🚀 核心功能
|
||||
|
||||
- **📡 多源全量采集**
|
||||
- **主流模型**:ECMWF, GFS, ICON, GEM, JMA 实时最高温同步。
|
||||
- **实测数据**:全球机场 METAR 定时报文 + 土耳其 MGM 局点官方实测。
|
||||
- **中心化纠偏**:针对安卡拉特别接入 `17130` (Center) 官方指挥中心数据。
|
||||
- **主流模型**:ECMWF、GFS、ICON、GEM、JMA、Open-Meteo 的日/小时指导。
|
||||
- **实测数据**:Aviation Weather / METAR 为主观测源,安卡拉叠加 Turkish MGM 官方网络。
|
||||
- **城市特化**:安卡拉保留 `17130`(`Ankara (Bölge/Center)`)领先站逻辑,不替代 LTAC 结算主站。
|
||||
- **⚖️ DEB 智能融合**
|
||||
- 基于近期 7 天历史表现,动态调整各模型权重的博弈预测。
|
||||
- **🔔 异动预警系统 (Alert Engine)**
|
||||
- **动量突变**:捕捉 30 分钟内的急剧温变。
|
||||
- **预测突破**:当实测击穿所有预报上限时触发告警。
|
||||
- **平流监测**:基于周边前导站的风向流场模拟,预测冷/暖平流的到达。
|
||||
- **🛡️ 智能压制逻辑**
|
||||
- **峰值保护**:当日高温峰值大概率已过时,自动转为静默/快照模式,拒绝骚扰。
|
||||
- **冷却管理**:同一信号路径支持全局与城市级双重 CD。
|
||||
- 基于城市历史表现与当前模型分歧动态调整权重。
|
||||
- **🧩 React 仪表盘运行时**
|
||||
- 类型化 Store + 类型化 Data Client + Leaflet/Chart.js 生命周期封装。
|
||||
- 点击城市:地图聚焦 + 右侧卡片打开 + 周边站点展示。
|
||||
- 点击“今日日内分析”:打开模态框并冻结地图动画。
|
||||
- **🔔 异动预警系统**
|
||||
- **动量突变**:捕捉短窗口温度斜率变化。
|
||||
- **预测突破**:实测突破模型包络与安全边际时触发。
|
||||
- **平流监测**:结合前导站和风向识别冷暖平流。
|
||||
|
||||
---
|
||||
|
||||
@@ -67,24 +68,40 @@ PolyWeather 是一套专为 **Polymarket** 深度博弈者设计的实时情报
|
||||
|
||||
---
|
||||
|
||||
## 🧭 当前数据逻辑
|
||||
|
||||
- **主观测源**:Aviation Weather / METAR
|
||||
- **安卡拉增强逻辑**:
|
||||
- 结算主观测:`LTAC / Esenboğa`
|
||||
- 官方领先站:`Ankara (Bölge/Center)` / `17130`
|
||||
- 周边站层:土耳其 MGM 网络(含安卡拉优先站筛选)
|
||||
- **其他城市周边站层**:
|
||||
- 当前生产环境使用 Aviation Weather METAR cluster
|
||||
- 美国城市后续可叠加 Mesonet,但 METAR 仍为基础层
|
||||
- **前端请求优化口径**:
|
||||
- 首屏先走 `/api/city/{name}/summary` 预热地图温度
|
||||
- 城市详情 5 分钟 TTL,revision 不变则跳过重拉
|
||||
- 地图联动、侧卡开关、modal 开关不会重复请求
|
||||
- 手动刷新强制绕过缓存(`force_refresh=true`)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 架构解析
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "客户端 / 终端"
|
||||
Web[Next.js 网页端]
|
||||
Web[Next.js React 网页端]
|
||||
TG[Telegram 客户端]
|
||||
end
|
||||
|
||||
subgraph "云端部署 (Vercel)"
|
||||
Web -.-> |Auth| Supa[(Supabase Auth/DB)]
|
||||
Web --> |API| Fast[FastAPI API]
|
||||
Web --> |BFF 路由| Fast[FastAPI API]
|
||||
end
|
||||
|
||||
subgraph "核心引擎 (VPS)"
|
||||
Fast --- |Shared Logic| Worker[Alert Engine / Worker]
|
||||
Bot[Telegram Bot] --- |Shared Logic| Worker
|
||||
Worker --> |Cache/Sub| Supa
|
||||
end
|
||||
|
||||
subgraph "外部数据源"
|
||||
@@ -117,7 +134,7 @@ docker-compose up -d --build
|
||||
|
||||
### 2. 前端 (Vercel)
|
||||
|
||||
直接关联本项目 `frontend` 目录作为根目录即可,享受自动 CI/CD。
|
||||
关联本项目 `frontend` 目录作为根目录,启用自动 CI/CD。
|
||||
|
||||
---
|
||||
|
||||
@@ -127,21 +144,25 @@ docker-compose up -d --build
|
||||
| :-------- | :------------------------ | :------------- |
|
||||
| `/city` | 查询指定城市实时分析 | `/city ankara` |
|
||||
| `/deb` | 查看 DEB 模型的历史准确率 | `/deb london` |
|
||||
| `/points` | 查看您的活跃积分与排行榜 | `/points` |
|
||||
| `/top` | 查看活跃积分排行榜 | `/top` |
|
||||
| `/help` | 获取详细功能说明 | `/help` |
|
||||
|
||||
---
|
||||
|
||||
> [!NOTE]
|
||||
> **商业化提示**:本项目目前提供 **Web 仪表盘 ($5/月)** 与 **Telegram 信号频道 ($1/月)** 订阅服务。
|
||||
> 发言获取积分逻辑已上线,活跃用户可兑换相应权限。
|
||||
> **商业化提示**:当前仍以 **Web 仪表盘 ($5/月)** 与 **Telegram 信号频道 ($1/月)** 为核心入口套餐。
|
||||
> 自动化支付与订阅鉴权规划见 `docs/COMMERCIALIZATION.md`。
|
||||
|
||||
> [!NOTE]
|
||||
> **前端现状**:生产环境页面已由 `frontend/components/dashboard` 与 `frontend/hooks` 完整接管渲染。
|
||||
> legacy 静态文件仅保留为历史参考,不再作为主运行入口。
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
**📅 最后更新**:2026-03-08
|
||||
**🚀 状态**:v1.0 稳定版 - 专业量化 UI 已锁定
|
||||
**📅 最后更新**:2026-03-09
|
||||
**🚀 状态**:v1.1 稳定版 - React 运行时已上线
|
||||
|
||||
> [!TIP]
|
||||
> **生产提示**:当前仪表盘采用高密度“专业量化版” UI (v1.0-legacy),深度集成了 METAR/MGM 实测数据、DEB 智能融合预报及多模型概率分布,提供最高性能的数据交互体验。
|
||||
> **生产提示**:在不改变既有 UI 布局与视觉层级的前提下,数据流、地图联动和模态行为已迁移到类型化 React 组件体系。
|
||||
|
||||
@@ -16,9 +16,9 @@ from src.data_collection.city_risk_profiles import get_city_risk_profile # type
|
||||
from src.analysis.deb_algorithm import calculate_dynamic_weights, update_daily_record # noqa: E402
|
||||
from src.database.db_manager import DBManager
|
||||
|
||||
MESSAGE_POINTS = 1
|
||||
MESSAGE_POINTS = 4
|
||||
MESSAGE_DAILY_CAP = 50
|
||||
MESSAGE_MIN_LENGTH = 4
|
||||
MESSAGE_MIN_LENGTH = 2
|
||||
MESSAGE_COOLDOWN_SEC = 30
|
||||
CITY_QUERY_COST = 1
|
||||
DEB_QUERY_COST = 1
|
||||
@@ -63,8 +63,8 @@ def start_bot():
|
||||
f"当前积分: <code>{balance}</code>\n"
|
||||
f"需要积分: <code>{required}</code>\n"
|
||||
f"还差积分: <code>{missing}</code>\n\n"
|
||||
f"积分规则:群内有效发言满 {MESSAGE_MIN_LENGTH} 字,"
|
||||
f"每次 +{MESSAGE_POINTS} 分,每日上限 {MESSAGE_DAILY_CAP} 分。"
|
||||
f"积分规则:每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
@@ -80,7 +80,7 @@ def start_bot():
|
||||
"/top - 查看积分排行榜\n"
|
||||
"/id - 获取当前聊天的 Chat ID\n\n"
|
||||
"示例: <code>/city 伦敦</code>\n"
|
||||
f"💡 <i>提示: 群内有效发言满 {MESSAGE_MIN_LENGTH} 字,每次 +{MESSAGE_POINTS} 分,"
|
||||
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>"
|
||||
)
|
||||
bot.reply_to(message, welcome_text, parse_mode="HTML")
|
||||
@@ -118,33 +118,37 @@ def start_bot():
|
||||
)
|
||||
|
||||
bot.send_message(message.chat.id, rank_text, parse_mode="HTML")
|
||||
|
||||
@bot.message_handler(commands=["deb"])
|
||||
def deb_accuracy(message):
|
||||
"""查询 DEB 融合预测的历史准确率"""
|
||||
"""查询 DEB 融合预测的近 7 天准确率。"""
|
||||
try:
|
||||
parts = message.text.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
bot.reply_to(
|
||||
message, "❓ 用法: <code>/deb ankara</code>", parse_mode="HTML"
|
||||
message,
|
||||
"❌ 用法: <code>/deb ankara</code>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
import os as _os
|
||||
|
||||
from src.analysis.deb_algorithm import load_history
|
||||
from src.data_collection.city_registry import ALIASES
|
||||
|
||||
city_input = parts[1].strip().lower()
|
||||
city_name = ALIASES.get(city_input, city_input)
|
||||
|
||||
from src.analysis.deb_algorithm import load_history
|
||||
import os as _os
|
||||
|
||||
# 获取详细历史数据
|
||||
project_root = _os.path.dirname(_os.path.abspath(__file__))
|
||||
history_file = _os.path.join(project_root, "data", "daily_records.json")
|
||||
data = load_history(history_file)
|
||||
|
||||
if city_name not in data or not data[city_name]:
|
||||
bot.reply_to(
|
||||
message, f"❌ 暂无 {city_name} 的历史数据", parse_mode="HTML"
|
||||
message,
|
||||
f"❌ 暂无 {city_name} 的历史数据。",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
@@ -152,28 +156,40 @@ def start_bot():
|
||||
return
|
||||
|
||||
city_data = data[city_name]
|
||||
from datetime import datetime as _dt
|
||||
today = _dt.now().date()
|
||||
today_str = today.strftime("%Y-%m-%d")
|
||||
cutoff_date = today - _td(days=6)
|
||||
|
||||
today_str = _dt.now().strftime("%Y-%m-%d")
|
||||
recent_items = []
|
||||
for date_str, record in city_data.items():
|
||||
try:
|
||||
row_date = _dt.strptime(date_str, "%Y-%m-%d").date()
|
||||
except Exception:
|
||||
continue
|
||||
if row_date >= cutoff_date:
|
||||
recent_items.append((date_str, record, row_date))
|
||||
|
||||
lines = [f"📊 <b>DEB 准确率报告 - {city_name.title()}</b>\n"]
|
||||
recent_items.sort(key=lambda item: item[0])
|
||||
|
||||
# 逐日明细
|
||||
lines.append("<b>📅 逐日记录:</b>")
|
||||
lines = [
|
||||
f"📊 <b>DEB 准确率报告 - {city_name.title()}</b>",
|
||||
"",
|
||||
"📅 <b>近7日记录:</b>",
|
||||
]
|
||||
total_days = 0
|
||||
hits = 0
|
||||
deb_errors = []
|
||||
signed_errors = [] # 有正负的误差 (DEB - 实测)
|
||||
signed_errors = []
|
||||
model_errors = {}
|
||||
|
||||
for date_str in sorted(city_data.keys()):
|
||||
record = city_data[date_str]
|
||||
for date_str, record, _row_date in recent_items:
|
||||
actual = record.get("actual_high")
|
||||
deb_pred = record.get("deb_prediction")
|
||||
forecasts = record.get("forecasts", {})
|
||||
forecasts = record.get("forecasts", {}) or {}
|
||||
|
||||
if actual is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
actual = float(actual)
|
||||
if deb_pred is not None:
|
||||
@@ -181,18 +197,16 @@ def start_bot():
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 如果没有存 DEB 预测值,用当天各模型平均值回算
|
||||
if deb_pred is None and forecasts:
|
||||
valid_preds = [
|
||||
float(v) for v in forecasts.values() if v is not None
|
||||
]
|
||||
valid_preds = [float(v) for v in forecasts.values() if v is not None]
|
||||
if valid_preds:
|
||||
deb_pred = round(sum(valid_preds) / len(valid_preds), 1)
|
||||
|
||||
actual_wu = round(actual)
|
||||
|
||||
# DEB 命中判断
|
||||
if deb_pred is not None and date_str != today_str:
|
||||
if date_str == today_str:
|
||||
lines.append(f" {date_str}: 📍 今天进行中 (实测暂 {actual:.1f})")
|
||||
elif deb_pred is not None:
|
||||
total_days += 1
|
||||
deb_wu = round(deb_pred)
|
||||
hit = deb_wu == actual_wu
|
||||
@@ -201,111 +215,97 @@ def start_bot():
|
||||
err = deb_pred - actual
|
||||
deb_errors.append(abs(err))
|
||||
signed_errors.append(err)
|
||||
icon = "✅" if hit else "❌"
|
||||
retro = "≈" if "deb_prediction" not in record else ""
|
||||
# 错误类型标签
|
||||
if not hit:
|
||||
err_label = (
|
||||
f" 低估{abs(err):.1f}°"
|
||||
if err < 0
|
||||
else f" 高估{abs(err):.1f}°"
|
||||
)
|
||||
else:
|
||||
err_label = f" 偏差{abs(err):.1f}°"
|
||||
mu_val = record.get("mu")
|
||||
mu_str = f" | μ: {mu_val}" if mu_val is not None else ""
|
||||
lines.append(
|
||||
f" {date_str}: DEB {retro}{deb_pred}→<b>{deb_wu}</b> vs 实测 {actual}→<b>{actual_wu}</b> {icon}{err_label}{mu_str}"
|
||||
)
|
||||
elif date_str == today_str:
|
||||
lines.append(f" {date_str}: 📍 今天进行中 (实测暂 {actual})")
|
||||
|
||||
# 各模型误差统计
|
||||
if hit:
|
||||
result_icon = "✅"
|
||||
err_text = f"偏差{abs(err):.1f}°"
|
||||
elif err < 0:
|
||||
result_icon = "❌"
|
||||
err_text = f"低估{abs(err):.1f}°"
|
||||
else:
|
||||
result_icon = "❌"
|
||||
err_text = f"高估{abs(err):.1f}°"
|
||||
|
||||
retro = "≈" if "deb_prediction" not in record else ""
|
||||
lines.append(
|
||||
f" {date_str}: DEB {retro}{deb_pred:.1f}→{deb_wu} vs 实测 {actual:.1f}→{actual_wu} "
|
||||
f"{result_icon} {err_text}"
|
||||
)
|
||||
|
||||
if date_str != today_str and actual is not None:
|
||||
for model, pred in forecasts.items():
|
||||
if pred is not None:
|
||||
if model not in model_errors:
|
||||
model_errors[model] = []
|
||||
model_errors[model].append(abs(float(pred) - actual))
|
||||
if pred is None:
|
||||
continue
|
||||
try:
|
||||
model_errors.setdefault(model, []).append(abs(float(pred) - actual))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 汇总
|
||||
if total_days > 0:
|
||||
hit_rate = hits / total_days * 100
|
||||
deb_mae = sum(deb_errors) / len(deb_errors)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"\n🎯 <b>DEB 总战绩</b>:WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | MAE: {deb_mae:.1f}°"
|
||||
f"🎯 <b>DEB 总战绩:</b>WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | MAE: {deb_mae:.1f}°"
|
||||
)
|
||||
|
||||
# --- 概率引擎 μ 的战绩 ---
|
||||
from src.analysis.deb_algorithm import get_mu_accuracy
|
||||
mu_acc = get_mu_accuracy(city_name)
|
||||
if mu_acc:
|
||||
mu_mae, mu_hr, avg_brier, mu_total, _ = mu_acc
|
||||
lines.append(
|
||||
f"🎲 <b>概率引擎 (μ)</b>:WU命中 <b>{mu_hr:.0f}%</b> | MAE: {mu_mae:.1f}°"
|
||||
)
|
||||
if avg_brier is not None:
|
||||
# Brier Score 范围是 0 (完美) 到 2 (全错)
|
||||
bs_eval = "极佳" if avg_brier < 0.2 else ("良好" if avg_brier < 0.4 else "需校准")
|
||||
lines.append(f" ▪ Brier评分: {avg_brier:.3f} ({bs_eval})")
|
||||
|
||||
|
||||
# 和各模型 MAE 对比
|
||||
if model_errors:
|
||||
lines.append("\n📈 <b>模型 MAE 对比</b>:")
|
||||
model_maes = {
|
||||
m: sum(e) / len(e) for m, e in model_errors.items() if e
|
||||
}
|
||||
sorted_models = sorted(model_maes.items(), key=lambda x: x[1])
|
||||
for m, mae in sorted_models:
|
||||
lines.append("")
|
||||
lines.append("📈 <b>模型 MAE 对比:</b>")
|
||||
model_maes = {m: sum(e) / len(e) for m, e in model_errors.items() if e}
|
||||
sorted_models = sorted(model_maes.items(), key=lambda item: item[1])
|
||||
for model, mae in sorted_models:
|
||||
tag = " ⭐" if mae <= deb_mae else ""
|
||||
lines.append(f" {m}: {mae:.1f}°{tag}")
|
||||
lines.append(f" {model}: {mae:.1f}°{tag}")
|
||||
lines.append(f" <b>DEB融合: {deb_mae:.1f}°</b>")
|
||||
|
||||
# 偏差模式分析
|
||||
mean_bias = sum(signed_errors) / len(signed_errors)
|
||||
underest = sum(1 for e in signed_errors if e < -0.3)
|
||||
overest = sum(1 for e in signed_errors if e > 0.3)
|
||||
accurate = total_days - underest - overest
|
||||
|
||||
lines.append("\n🔍 <b>偏差分析</b>:")
|
||||
lines.append("")
|
||||
lines.append("🔍 <b>偏差分析:</b>")
|
||||
if abs(mean_bias) > 0.3:
|
||||
bias_dir = "低估" if mean_bias < 0 else "高估"
|
||||
lines.append(f" ⚠️ 系统性{bias_dir}:平均偏差 {mean_bias:+.1f}°")
|
||||
bias_label = "系统性低估" if mean_bias < 0 else "系统性高估"
|
||||
lines.append(f" ⚠️ {bias_label}:平均偏差 {mean_bias:+.1f}°")
|
||||
else:
|
||||
lines.append(f" ✅ 无明显系统偏差(平均 {mean_bias:+.1f}°)")
|
||||
lines.append(
|
||||
f" 低估 {underest} 次 | 高估 {overest} 次 | 准确 {total_days - underest - overest} 次"
|
||||
)
|
||||
lines.append(f" ✅ 整体无明显系统偏差:平均偏差 {mean_bias:+.1f}°")
|
||||
lines.append(f" 低估 {underest} 次 | 高估 {overest} 次 | 准确 {accurate} 次")
|
||||
|
||||
# 可操作建议
|
||||
lines.append("\n💡 <b>建议</b>:")
|
||||
lines.append("")
|
||||
lines.append("💡 <b>建议:</b>")
|
||||
if underest > overest and abs(mean_bias) > 0.5:
|
||||
lines.append(
|
||||
f" 该城市模型集体低估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值高 {abs(mean_bias):.0f}-{abs(mean_bias) + 0.5:.0f}°。交易时建议适当看高。"
|
||||
f" 该城市模型集体低估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值高 "
|
||||
f"{abs(mean_bias):.0f}-{abs(mean_bias) + 0.5:.0f}°。交易时建议适当看高。"
|
||||
)
|
||||
elif overest > underest and abs(mean_bias) > 0.5:
|
||||
lines.append(
|
||||
f" 该城市模型集体高估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值低。交易时建议适当看低。"
|
||||
f" 该城市模型集体高估趋势明显({mean_bias:+.1f}°),实际最高温可能低于 DEB 融合值。交易时注意追高风险。"
|
||||
)
|
||||
elif deb_mae > 1.5:
|
||||
lines.append(
|
||||
f" 该城市预报波动大 (MAE {deb_mae:.1f}°),建议观望或轻仓。"
|
||||
)
|
||||
lines.append(f" 近期模型波动较大(MAE {deb_mae:.1f}°),建议降低对单一日预测的信任度。")
|
||||
elif hit_rate >= 60:
|
||||
lines.append(" DEB 表现良好,可作为主要参考。")
|
||||
lines.append(" DEB 近期表现稳定,可继续作为主要参考。")
|
||||
else:
|
||||
lines.append(" 数据积累中,建议结合 AI 分析综合判断。")
|
||||
lines.append(" 近期准确率一般,建议结合主站实测与周边站点共同判断。")
|
||||
|
||||
lines.append("\n📝 MAE = 平均绝对误差,越小越准。⭐ = 优于 DEB 融合。")
|
||||
lines.append("")
|
||||
lines.append("📝 MAE = 平均绝对误差,越小越准。⭐ = 优于 DEB 融合。")
|
||||
lines.append("🗓 统计窗口:近7天滚动样本。")
|
||||
else:
|
||||
lines.append("\n⏳ 尚无完整的 DEB 预测记录,明天起开始统计。")
|
||||
lines.append("")
|
||||
lines.append("⏳ 近7天尚无完整的 DEB 预测记录。")
|
||||
|
||||
lines.append(f"\n💳 本次消耗 <code>{DEB_QUERY_COST}</code> 积分。")
|
||||
lines.append("")
|
||||
lines.append(f"💳 本次消耗 <code>{DEB_QUERY_COST}</code> 积分。")
|
||||
bot.reply_to(message, "\n".join(lines), parse_mode="HTML")
|
||||
except Exception as e:
|
||||
bot.reply_to(message, f"❌ 查询失败: {e}")
|
||||
|
||||
@bot.message_handler(commands=["city"])
|
||||
|
||||
def get_city_info(message):
|
||||
"""查询指定城市的天气详情"""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# PolyWeather API 接口文档 (v1.2)
|
||||
|
||||
本文档说明当前 PolyWeather 后端实际提供的 HTTP API。后端由 `web/app.py` 提供,前端通过 Next.js BFF 路由代理访问这些接口。
|
||||
|
||||
---
|
||||
|
||||
## 1. 基础信息
|
||||
|
||||
- **本地 Base URL**: `http://127.0.0.1:8000`
|
||||
- **生产 Base URL**: `http://<your-vps-ip>:8000` 或绑定后的 HTTPS API 域名
|
||||
- **响应格式**: JSON
|
||||
- **缓存策略**:
|
||||
- 后端 `web/app.py` 内部分析缓存:默认 5 分钟(Ankara 为 60 秒)
|
||||
- 前端城市详情缓存:5 分钟 TTL + revision 校验
|
||||
- 前端手动刷新:强制 `force_refresh=true` 跳过缓存
|
||||
|
||||
---
|
||||
|
||||
## 2. 接口列表
|
||||
|
||||
### 2.1 获取监控城市列表
|
||||
|
||||
- **URL**: `/api/cities`
|
||||
- **Method**: `GET`
|
||||
- **用途**: 返回首页左侧监控城市与地图 marker 的基础元数据。
|
||||
|
||||
**响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"cities": [
|
||||
{
|
||||
"name": "ankara",
|
||||
"display_name": "Ankara",
|
||||
"lat": 40.1281,
|
||||
"lon": 32.9951,
|
||||
"risk_level": "medium",
|
||||
"risk_emoji": "🟠",
|
||||
"airport": "Esenboğa",
|
||||
"icao": "LTAC",
|
||||
"temp_unit": "celsius",
|
||||
"is_major": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 获取城市实时分析
|
||||
|
||||
- **URL**: `/api/city/{name}`
|
||||
- **Method**: `GET`
|
||||
- **参数**:
|
||||
- `name`: 城市名或别名,如 `ankara`、`new-york`
|
||||
- `force_refresh` (可选): `true` 时跳过缓存
|
||||
- **用途**: 右侧详情卡片、今日分析 modal、图表和周边站点的主数据接口。
|
||||
|
||||
**当前核心字段**
|
||||
|
||||
- `display_name`
|
||||
- `local_time`
|
||||
- `local_date`
|
||||
- `temp_symbol`
|
||||
- `risk`
|
||||
- `current`
|
||||
- `mgm`
|
||||
- `mgm_nearby`
|
||||
- `forecast`
|
||||
- `multi_model`
|
||||
- `deb`
|
||||
- `ensemble`
|
||||
- `probabilities`
|
||||
- `trend`
|
||||
- `metar_today_obs`
|
||||
- `metar_recent_obs`
|
||||
- `hourly`
|
||||
- `hourly_next_48h`
|
||||
- `source_forecasts`
|
||||
- `multi_model_daily`
|
||||
- `updated_at`
|
||||
|
||||
**说明**
|
||||
|
||||
- `current.raw_metar` 为 Aviation Weather 返回的原始报文字段。
|
||||
- `mgm` 仅在具备官方 MGM 覆盖的城市(如 Ankara)有效。
|
||||
- `mgm_nearby` 为统一周边站点字段:
|
||||
- Ankara:MGM 官方周边站
|
||||
- 其他多数城市:METAR cluster
|
||||
|
||||
### 2.3 获取历史对账数据
|
||||
|
||||
- **URL**: `/api/history/{name}`
|
||||
- **Method**: `GET`
|
||||
- **用途**: 历史对账弹窗与 `/deb` 指令的历史样本来源。
|
||||
|
||||
**响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-03-07",
|
||||
"actual": 7.0,
|
||||
"deb": 6.5,
|
||||
"mu": 7.2,
|
||||
"mgm": 8.0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**说明**
|
||||
|
||||
- 网页端历史图默认展示近期样本,但统计口径只使用已结算日期。
|
||||
- 当天未结算样本可用于可视化趋势,不计入胜率与 MAE。
|
||||
|
||||
### 2.4 获取城市摘要
|
||||
|
||||
- **URL**: `/api/city/{name}/summary`
|
||||
- **Method**: `GET`
|
||||
- **用途**: 轻量级温度摘要接口,用于首屏地图温度预热与低开销列表更新。
|
||||
|
||||
**字段**
|
||||
|
||||
- `name`
|
||||
- `display_name`
|
||||
- `icao`
|
||||
- `local_time`
|
||||
- `temp_symbol`
|
||||
- `current.temp`
|
||||
- `current.obs_time`
|
||||
- `deb.prediction`
|
||||
- `risk.level`
|
||||
- `risk.warning`
|
||||
- `updated_at`
|
||||
|
||||
### 2.5 获取城市聚合详情
|
||||
|
||||
- **URL**: `/api/city/{name}/detail`
|
||||
- **Method**: `GET`
|
||||
- **用途**: 面向后续商业化聚合视图的单请求聚合接口。
|
||||
|
||||
**当前结构**
|
||||
|
||||
- `overview`
|
||||
- `official`
|
||||
- `timeseries`
|
||||
- `models`
|
||||
- `probabilities`
|
||||
- `market_scan`
|
||||
- `risk`
|
||||
- `ai_analysis`
|
||||
|
||||
**说明**
|
||||
|
||||
- 当前生产前端主链路仍以 `/api/city/{name}` + `/api/history/{name}` 为主。
|
||||
- `/api/city/{name}/detail` 已提供聚合结构,供后续产品层扩展接入。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心对象定义
|
||||
|
||||
### 3.1 风险等级
|
||||
|
||||
- `low`: 低风险,模型与实测整体较一致
|
||||
- `medium`: 中风险,存在一定分歧或站点偏置
|
||||
- `high`: 高风险,模型冲突较大或盘面波动价值高
|
||||
|
||||
### 3.2 DEB
|
||||
|
||||
`DEB` 是 PolyWeather 的动态融合预测层,不是简单平均值。它会综合:
|
||||
|
||||
- 多模型预测值
|
||||
- 近期表现
|
||||
- 城市级偏差特征
|
||||
- 实况修正上下文
|
||||
|
||||
### 3.3 μ
|
||||
|
||||
`μ` 表示当前结算概率分布中心(动态期望值),会随模型分歧与实况变化而更新。
|
||||
它不应直接按固定 forecast 口径做静态历史对账。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据源与第三方 API
|
||||
|
||||
### 4.1 主观测源
|
||||
|
||||
- **Aviation Weather / METAR**
|
||||
- 全球机场主观测源
|
||||
- 同时提供结构化字段与原始 METAR 报文
|
||||
|
||||
### 4.2 Ankara 专属源
|
||||
|
||||
- **Turkish MGM**
|
||||
- Ankara 官方增强层
|
||||
- 含 `Ankara (Bölge/Center)` 与周边站点
|
||||
|
||||
### 4.3 预测源
|
||||
|
||||
- **Open-Meteo**
|
||||
- **weather.gov**(美国城市)
|
||||
- **Meteoblue**(部分城市)
|
||||
- **多模型集成**: ECMWF / GFS / ICON / GEM / JMA
|
||||
|
||||
---
|
||||
|
||||
## 5. 当前口径说明
|
||||
|
||||
- 地图 marker 显示当前温度(首屏通过 `summary` 预热)。
|
||||
- 点击城市后打开右侧详情卡片,保持当前布局与样式不变。
|
||||
- “今日日内分析”在 modal 中展示:
|
||||
- 今日温度走势(含 METAR 实测点)
|
||||
- 结算概率分布
|
||||
- 多模型预报
|
||||
- 今日日内结构信号(规则引擎)
|
||||
- AI 深度分析 + 0-2 小时临近判断
|
||||
- modal 打开时地图停止动画;点击空白地图仅关闭右侧卡片,不重置视角。
|
||||
|
||||
---
|
||||
|
||||
## 6. 常见问题
|
||||
|
||||
- **接口 500**
|
||||
- 先检查 `polyweather_web` 是否启动成功
|
||||
- 再看 `docker-compose logs -f polyweather_web`
|
||||
|
||||
- **METAR 看起来慢几分钟**
|
||||
- 常见原因是上游发布延迟,不一定是本地轮询问题
|
||||
- 建议同时查看:
|
||||
- `current.obs_time`
|
||||
- `current.report_time`
|
||||
- `current.receipt_time`
|
||||
|
||||
- **网页显示旧内容**
|
||||
- 先确认 Vercel 已部署最新版本
|
||||
- 再强刷浏览器缓存
|
||||
- 如为详情数据,确认是否命中前端 5 分钟 TTL
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-03-09
|
||||
@@ -1,4 +1,4 @@
|
||||
# 📈 Commercialization Roadmap
|
||||
# 📈 Commercialization Roadmap
|
||||
|
||||
> **Target**: Transforming PolyWeather for paid weather intelligence delivery.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 🎯 Product Focus
|
||||
|
||||
PolyWeather is positioned as a **premium intelligence service** for weather-based prediction markets (**Polymarket**). The value proposition lies in **Ankara-specialization**, **advanced advection forecasting**, and **DEB-weighted consensus**.
|
||||
PolyWeather is positioned as a **premium intelligence service** for weather-driven prediction markets (**Polymarket**). The core differentiators remain **Ankara specialization**, **advection-aware signal logic**, and **DEB-weighted consensus**.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,13 +15,13 @@ PolyWeather is positioned as a **premium intelligence service** for weather-base
|
||||
| Tier | Price | Primary Value Proposition |
|
||||
| :------------------- | :------------ | :------------------------------------------------------------ |
|
||||
| **Telegram Channel** | **$1 / mo** | High-fidelity proactive alerts, low noise. |
|
||||
| **Web Dashboard** | **$5 / mo** | Comprehensive multi-model view + historical MAE benchmarking. |
|
||||
| **VIP Bundle** | **$5.5 / mo** | Full access to all intelligence streams. |
|
||||
| **Web Dashboard** | **$5 / mo** | Full multi-model context + historical DEB benchmarking. |
|
||||
| **VIP Bundle** | **$5.5 / mo** | Unified access to dashboard + signal stream. |
|
||||
|
||||
### 🛠️ Payment Infrastructure
|
||||
|
||||
- **Currency**: Polygon / USDC.
|
||||
- **Method**: Initially manual activation; migrating to automatic deposit detection (Phase 2).
|
||||
- **Method**: Phase-1 manual activation; Phase-2 automatic deposit detection and entitlement sync.
|
||||
|
||||
---
|
||||
|
||||
@@ -45,35 +45,51 @@ graph LR
|
||||
|
||||
### 📦 Phase 1: Manual Beta
|
||||
|
||||
- **Goal**: Stabilize current alert quality and build core user group.
|
||||
- **Goal**: Stabilize signal quality and convert initial paid users.
|
||||
- **Actions**:
|
||||
- Manual subscription activation via Telegram DM.
|
||||
- Small, focused paid Telegram channel for signal tests.
|
||||
- Invitation-only Web Access (Vercel).
|
||||
- Small paid Telegram channel for low-noise signal validation.
|
||||
- Invite-based Web access while entitlement layer is being finalized.
|
||||
- Keep Ankara as flagship strategy city for product credibility.
|
||||
|
||||
### 🛠️ Phase 2: Automation (USDC)
|
||||
|
||||
- **Goal**: Reduce operational friction.
|
||||
- **Goal**: Reduce operational friction and improve payment reliability.
|
||||
- **Actions**:
|
||||
- **On-chain monitoring**: Detect USDC deposits to unique addresses.
|
||||
- **One-time Links**: Telegram bot automatically generates invite links with `member_limit=1`.
|
||||
- **JWT Auth**: Securing the Next.js frontend with subscriber-only tokens.
|
||||
- **On-chain monitoring**: Detect USDC deposits to dedicated addresses.
|
||||
- **One-time Links**: Bot-generated invite links with strict member limits.
|
||||
- **JWT Auth**: Subscriber-only access control for the Next.js frontend.
|
||||
|
||||
### 🌐 Phase 3: Scaling & Analytics
|
||||
|
||||
- **Goal**: Retention and expansion.
|
||||
- **Goal**: Improve retention and expand B2C/B2B utility.
|
||||
- **Actions**:
|
||||
- **Accuracy Leaderboard**: Monthly reports of DEB vs Market outcomes.
|
||||
- **Self-Serve Portal**: User dashboard for billing and alert settings.
|
||||
- **Accuracy Leaderboard**: Monthly DEB vs settled-actual reports.
|
||||
- **Self-Serve Portal**: Billing, subscription status, and alert preferences.
|
||||
- **Usage Telemetry**: Feature-level analytics for conversion optimization.
|
||||
|
||||
### 📡 API Expansion Priority
|
||||
|
||||
- **P0-1 Market Layer**
|
||||
- Polymarket Gamma discovery + `py-clob-client` pricing / order book
|
||||
- **P0-2 Official Observation Layer**
|
||||
- Aviation Weather / METAR
|
||||
- weather.gov official forecast / observation / alert context
|
||||
- **P1 Lead Layer**
|
||||
- Ankara keeps Turkish MGM nearby network
|
||||
- U.S. cities may later receive Mesonet enhancement without replacing METAR
|
||||
- **P2 Product Layer**
|
||||
- Stripe / Polygon-USDC automation
|
||||
- Realtime entitlement sync and subscriber state management
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Critical Constraints
|
||||
|
||||
- **Weather-First**: We focus on the **physical variable changes** rather than exchange-side order book execution.
|
||||
- **Quality > Quantity**: Alert fatigue will churn subscribers. We enforce a "True Probability Shift" rule for notifications.
|
||||
- **Local Niche**: Ankara is our flagship differentiator.
|
||||
- **Weather-First**: The product is built around physical weather shifts, not exchange-side execution tooling.
|
||||
- **Quality > Quantity**: Alert fatigue directly harms retention; thresholds must favor actionable rarity.
|
||||
- **UI Stability**: Commercial rollout assumes layout consistency; visual contract stays fixed while internals evolve.
|
||||
|
||||
---
|
||||
|
||||
**📅 Last Updated**: 2026-03-06
|
||||
**📅 Last Updated**: 2026-03-09
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# 🛠️ Technical Debt & Engineering Backlog
|
||||
# 🛠️ Technical Debt & Engineering Backlog
|
||||
|
||||
> **Vision**: Moving from a research script to a production SaaS.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ System Health: 75%
|
||||
## 🏛️ System Health: 82%
|
||||
|
||||
```mermaid
|
||||
pie title System Health & Tech Debt
|
||||
"Stable Engine" : 75
|
||||
"Centralized Logic Debt" : 10
|
||||
"Subscription DB Debt" : 10
|
||||
"Testing/Replay Debt" : 5
|
||||
"Stable Engine" : 82
|
||||
"Entitlement/Payments Debt" : 8
|
||||
"Test/Replay Debt" : 6
|
||||
"Observability Debt" : 4
|
||||
```
|
||||
|
||||
The core engine is stable, but several infrastructure "shortcut" decisions remain.
|
||||
The core weather engine and React dashboard runtime are now stable, but product-layer infrastructure debt is still material.
|
||||
|
||||
### Current Stable Modules
|
||||
|
||||
@@ -22,6 +22,7 @@ The core engine is stable, but several infrastructure "shortcut" decisions remai
|
||||
- [x] DEB Blending Algorithm
|
||||
- [x] Proactive Telegram Alert Engine
|
||||
- [x] Vercel Dashboard Infrastructure
|
||||
- [x] React component-driven dashboard runtime (replacing legacy `public/static/app.js` rendering path)
|
||||
|
||||
---
|
||||
|
||||
@@ -32,6 +33,7 @@ The core engine is stable, but several infrastructure "shortcut" decisions remai
|
||||
| **Monolithic Bot** | `bot_listener.py` is hard to test and evolve. | Isolate UI interaction from business logic into `src/analysis`. |
|
||||
| **Subscription Store** | No persistent record of who has paid. | Migrate from in-memory user checks to **Supabase/PostgreSQL**. |
|
||||
| **Alert Transparency** | Operators cannot easily audit "why" an alert fired. | Add an `Evidence` metadata block to all internal alert payloads. |
|
||||
| **Entitlement Guard** | Dashboard routes are public by default. | Add JWT/session gating in Next.js middleware + backend checks. |
|
||||
|
||||
---
|
||||
|
||||
@@ -42,6 +44,7 @@ The core engine is stable, but several infrastructure "shortcut" decisions remai
|
||||
| **Hard-coded Thresholds** | Modification requires code changes (e.g., 5s CD). | Extract all business constants into a structured `config.yaml`. |
|
||||
| **Simulation Harness** | No way to "replay" a rainy day to test alert logic. | Build a `ReplayEngine` using `data/daily_records.json`. |
|
||||
| **Backend Naming** | Artifacts of "market price" logic remain in naming. | Systematic refactor of variable names to reflect weather-intelligence focus. |
|
||||
| **Chart Regression Tests**| UI relies on custom Chart.js lifecycles. | Add snapshot + interaction tests for chart datasets and legends. |
|
||||
|
||||
---
|
||||
|
||||
@@ -57,9 +60,10 @@ The core engine is stable, but several infrastructure "shortcut" decisions remai
|
||||
## 🗓️ Next Milestones
|
||||
|
||||
1. **DB Integration**: Connect Supabase to `src/database/db_manager.py`.
|
||||
2. **Alert Transparency**: Append logic metrics (slope, lead delta) to push messages.
|
||||
3. **Authentication**: Secure `/api/city` on Vercel with subscription keys.
|
||||
2. **Entitlement Layer**: Enforce paid-access middleware on dashboard and API proxy routes.
|
||||
3. **Alert Transparency**: Append logic metrics (slope, lead delta, advection factors) to push payloads.
|
||||
4. **Replay & QA**: Add deterministic replay tests for map/panel/modal interaction regressions.
|
||||
|
||||
---
|
||||
|
||||
**📅 Last Updated**: 2026-03-06
|
||||
**📅 Last Updated**: 2026-03-09
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 🛠️ 技术债与工程待办
|
||||
|
||||
> **愿景**:从研究脚本演进为可持续的生产级 SaaS。
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ 系统健康度:82%
|
||||
|
||||
```mermaid
|
||||
pie title 系统健康度与技术债
|
||||
"稳定引擎" : 82
|
||||
"权限与支付债务" : 8
|
||||
"测试/回放债务" : 6
|
||||
"可观测性债务" : 4
|
||||
```
|
||||
|
||||
核心天气引擎与 React 仪表盘运行时已基本稳定,但产品层基础设施债务仍然明显。
|
||||
|
||||
### 当前稳定模块
|
||||
|
||||
- [x] 多源天气聚合
|
||||
- [x] DEB 融合算法
|
||||
- [x] 主动式 Telegram 预警引擎
|
||||
- [x] Vercel 仪表盘基础设施
|
||||
- [x] React 组件驱动仪表盘运行时(已替换 legacy `public/static/app.js` 渲染路径)
|
||||
|
||||
---
|
||||
|
||||
## 🔴 高优先级:立即处理
|
||||
|
||||
| 债务项 | 影响 | 建议修复 |
|
||||
| :-------------------- | :------------------------------------------------- | :--------------------------------------------------------------- |
|
||||
| **Monolithic Bot** | `bot_listener.py` 可测试性差,演进成本高。 | 将 UI 交互与业务逻辑解耦,沉入 `src/analysis`。 |
|
||||
| **Subscription Store**| 付费用户缺少持久化记录。 | 从内存校验迁移到 **Supabase/PostgreSQL**。 |
|
||||
| **Alert Transparency**| 运维侧难以审计“告警为何触发”。 | 为所有内部告警载荷增加 `Evidence` 元数据块。 |
|
||||
| **Entitlement Guard** | 仪表盘路由默认仍是公开可访问。 | 在 Next.js middleware 与后端校验中加入 JWT/会话权限守卫。 |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 中优先级:体验与效率
|
||||
|
||||
| 债务项 | 影响 | 建议修复 |
|
||||
| :---------------------- | :------------------------------------------------- | :---------------------------------------------------------------------- |
|
||||
| **Hard-coded Thresholds** | 阈值修改需要改代码(如 5s 冷却)。 | 将业务常量统一抽离到结构化 `config.yaml`。 |
|
||||
| **Simulation Harness** | 无法“回放历史天气日”验证告警逻辑。 | 基于 `data/daily_records.json` 构建 `ReplayEngine`。 |
|
||||
| **Backend Naming** | 仍有“市场价格时代”的命名残留。 | 系统化重命名,统一为 weather-intelligence 语义。 |
|
||||
| **Chart Regression Tests**| 图表依赖自定义 Chart.js 生命周期,回归风险高。 | 增加图表数据集与图例的快照测试 + 交互测试。 |
|
||||
|
||||
---
|
||||
|
||||
## 🟢 低优先级:性能优化
|
||||
|
||||
| 债务项 | 影响 | 建议修复 |
|
||||
| :----------------------- | :------------------------------------------------- | :--------------------------------------------------------------- |
|
||||
| **Serverless Cold Starts** | Vercel 首次 API 调用可能偏慢。 | 为主要城市接口增加边缘缓存或预热任务。 |
|
||||
| **Local SQLite Files** | 与 Vercel 短暂文件系统不兼容。 | 全面迁移到远程数据库(Supabase/Redis)。 |
|
||||
|
||||
---
|
||||
|
||||
## 🗓️ 下一阶段里程碑
|
||||
|
||||
1. **DB Integration**:将 Supabase 接入 `src/database/db_manager.py`。
|
||||
2. **Entitlement Layer**:在仪表盘与 API 代理路由上落实付费访问中间件。
|
||||
3. **Alert Transparency**:在推送载荷中附加逻辑指标(斜率、领先差、平流因子)。
|
||||
4. **Replay & QA**:为地图/侧卡/modal 联动补齐可复现回放测试。
|
||||
|
||||
---
|
||||
|
||||
**📅 最后更新**:2026-03-09
|
||||
@@ -1,4 +1,4 @@
|
||||
# PolyWeather Frontend
|
||||
# PolyWeather Frontend
|
||||
|
||||
This directory is the only web frontend in production.
|
||||
|
||||
@@ -8,20 +8,23 @@ Production URL:
|
||||
## Stack
|
||||
|
||||
- Next.js App Router
|
||||
- React (component-driven dashboard)
|
||||
- Tailwind CSS
|
||||
- Lucide React
|
||||
- shadcn/ui base layer
|
||||
- Legacy dashboard shell loaded from `public/legacy/index.html`
|
||||
- Leaflet (map runtime)
|
||||
- Chart.js (charts with manual lifecycle wrapper)
|
||||
- Typed store + typed data client
|
||||
|
||||
## Production Model
|
||||
|
||||
- Vercel serves the web UI
|
||||
- FastAPI on VPS serves API only
|
||||
- Vercel serves the web UI and BFF route handlers
|
||||
- FastAPI on VPS serves weather APIs only
|
||||
- The old FastAPI static website has been removed
|
||||
- The production page shell is React-driven (`components/dashboard/*`), with no runtime dependency on `public/static/app.js`
|
||||
|
||||
Current request flow:
|
||||
- Browser -> Vercel frontend
|
||||
- Vercel route handlers -> FastAPI API
|
||||
- React store/client -> Next route handlers
|
||||
- Next route handlers -> FastAPI API
|
||||
|
||||
## Local Development
|
||||
|
||||
@@ -50,8 +53,15 @@ Examples:
|
||||
Thin BFF routes currently exposed by Next:
|
||||
- `GET /api/cities`
|
||||
- `GET /api/city/[name]`
|
||||
- `GET /api/city/[name]/summary`
|
||||
- `GET /api/history/[name]`
|
||||
|
||||
Current frontend behavior:
|
||||
- `/` keeps the world overview layout and initial city temperatures preload
|
||||
- Marker click: focus map + open right city card + render nearby stations
|
||||
- Right-card "今日日内分析": opens modal and freezes map motion
|
||||
- Blank-map click: closes right card only, without resetting camera
|
||||
|
||||
## Vercel Deployment
|
||||
|
||||
1. Import the repo into Vercel
|
||||
@@ -62,7 +72,7 @@ Thin BFF routes currently exposed by Next:
|
||||
## Notes
|
||||
|
||||
- Backend CORS must allow `https://polyweather-pro.vercel.app`
|
||||
- The page shell currently embeds the legacy dashboard HTML from `public/legacy/index.html`
|
||||
- If you change files under `public/static`, deploy to Vercel to make them live
|
||||
- City detail cache TTL is 5 minutes with revision probe; manual refresh bypasses cache
|
||||
- UI layout and sizing remain aligned with the legacy visual contract after React migration
|
||||
|
||||
Last updated: 2026-03-06
|
||||
Last updated: 2026-03-09
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch city summary", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,106 +2,253 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: 223 53% 4%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 223 46% 8%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--primary: 190 95% 56%;
|
||||
--primary-foreground: 222 47% 8%;
|
||||
--secondary: 224 30% 14%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--accent: 217 30% 18%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--border: 221 38% 22%;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at 10% -10%, rgba(34, 211, 238, 0.2), transparent 40%),
|
||||
radial-gradient(circle at 90% 0%, rgba(59, 130, 246, 0.14), transparent 36%),
|
||||
radial-gradient(circle at 80% 100%, rgba(8, 47, 73, 0.5), transparent 48%),
|
||||
hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
background: rgba(2, 6, 23, 0.82) !important;
|
||||
border-color: rgba(71, 85, 105, 0.5) !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a:hover {
|
||||
background: rgba(15, 23, 42, 0.95) !important;
|
||||
}
|
||||
|
||||
.map-pill {
|
||||
min-width: 52px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 5px 10px;
|
||||
color: #f8fafc;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow:
|
||||
0 2px 14px rgba(2, 6, 23, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.map-pill.high {
|
||||
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||
}
|
||||
|
||||
.map-pill.medium {
|
||||
background: linear-gradient(135deg, #f59e0b, #b45309);
|
||||
}
|
||||
|
||||
.map-pill.low {
|
||||
background: linear-gradient(135deg, #10b981, #047857);
|
||||
}
|
||||
|
||||
.map-pill.active {
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(34, 211, 238, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(15, 23, 42, 0.9) 0%,
|
||||
rgba(2, 6, 23, 0.75) 100%
|
||||
);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.fade-up {
|
||||
animation: fadeUp 450ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 223 53% 4%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 223 46% 8%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--primary: 190 95% 56%;
|
||||
--primary-foreground: 222 47% 8%;
|
||||
--secondary: 224 30% 14%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--accent: 217 30% 18%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--border: 221 38% 22%;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 10% -10%,
|
||||
rgba(34, 211, 238, 0.2),
|
||||
transparent 40%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 90% 0%,
|
||||
rgba(59, 130, 246, 0.14),
|
||||
transparent 36%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 100%,
|
||||
rgba(8, 47, 73, 0.5),
|
||||
transparent 48%
|
||||
),
|
||||
hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.leaflet-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
background: rgba(2, 6, 23, 0.82) !important;
|
||||
border-color: rgba(71, 85, 105, 0.5) !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a:hover {
|
||||
background: rgba(15, 23, 42, 0.95) !important;
|
||||
}
|
||||
|
||||
.map-pill {
|
||||
min-width: 52px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 5px 10px;
|
||||
color: #f8fafc;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow:
|
||||
0 2px 14px rgba(2, 6, 23, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.map-pill.high {
|
||||
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||
}
|
||||
|
||||
.map-pill.medium {
|
||||
background: linear-gradient(135deg, #f59e0b, #b45309);
|
||||
}
|
||||
|
||||
.map-pill.low {
|
||||
background: linear-gradient(135deg, #10b981, #047857);
|
||||
}
|
||||
|
||||
.map-pill.active {
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(34, 211, 238, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(15, 23, 42, 0.9) 0%,
|
||||
rgba(2, 6, 23, 0.75) 100%
|
||||
);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.fade-up {
|
||||
animation: fadeUp 450ms ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@layer components {
|
||||
.nearby-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
color: rgba(226, 232, 240, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 10px 28px rgba(2, 6, 23, 0.45);
|
||||
backdrop-filter: blur(10px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nearby-name {
|
||||
color: rgba(148, 163, 184, 0.95);
|
||||
}
|
||||
|
||||
.nearby-temp {
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.nearby-unit {
|
||||
color: rgba(148, 163, 184, 0.85);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.wind-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
padding-left: 6px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(34, 211, 238, 0.9);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.wind-arrow {
|
||||
display: inline-block;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.city-marker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.marker-bubble {
|
||||
position: relative;
|
||||
min-width: 46px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
color: white;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.marker-bubble::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid transparent;
|
||||
}
|
||||
|
||||
.marker-bubble.risk-high {
|
||||
background: linear-gradient(135deg, #dc2626, #ef4444);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.marker-bubble.risk-high::after { border-top-color: #ef4444; }
|
||||
|
||||
.marker-bubble.risk-medium {
|
||||
background: linear-gradient(135deg, #d97706, #f59e0b);
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.marker-bubble.risk-medium::after { border-top-color: #f59e0b; }
|
||||
|
||||
.marker-bubble.risk-low {
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.marker-bubble.risk-low::after { border-top-color: #10b981; }
|
||||
|
||||
.marker-name {
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.city-marker.selected .marker-bubble {
|
||||
animation: markerGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(51, 65, 85, 0.75);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes markerGlow {
|
||||
0%, 100% { box-shadow: 0 6px 18px rgba(0,0,0,0.4); }
|
||||
50% { box-shadow: 0 10px 26px rgba(34,211,238,0.35); }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,18 @@ export default function RootLayout({
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh-CN" className="dark">
|
||||
<head>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen font-sans antialiased">
|
||||
{children}
|
||||
<Analytics />
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DashboardEntry } from "@/components/dashboard/DashboardEntry";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather - 天气衍生品智能地图",
|
||||
description:
|
||||
"PolyWeather 天气衍生品智能地图,聚合 METAR、MGM、DEB、多模型预报与历史对账分析。",
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="h-screen w-screen overflow-hidden bg-black">
|
||||
<iframe
|
||||
title="PolyWeather Legacy Dashboard"
|
||||
src="/legacy/index.html?v=legacy-v2"
|
||||
className="h-full w-full border-0"
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
return <DashboardEntry />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
|
||||
export function CitySidebar() {
|
||||
const store = useDashboardStore();
|
||||
const sortedCities = [...store.cities].sort((a, b) => {
|
||||
const order = { high: 0, medium: 1, low: 2 };
|
||||
return (
|
||||
(order[a.risk_level as keyof typeof order] ?? 3) -
|
||||
(order[b.risk_level as keyof typeof order] ?? 3)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<nav className="city-list">
|
||||
<div className="city-list-header">
|
||||
<span>监控城市</span>
|
||||
<span className="city-count">{store.cities.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="city-list-items">
|
||||
{sortedCities.map((city) => {
|
||||
const detail = store.cityDetailsByName[city.name];
|
||||
const summary = store.citySummariesByName[city.name];
|
||||
const snapshot = detail || summary;
|
||||
const isActive = store.selectedCity === city.name;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={city.name}
|
||||
type="button"
|
||||
className={clsx("city-item", isActive && "active")}
|
||||
onClick={() => void store.selectCity(city.name)}
|
||||
>
|
||||
<div className="city-item-main">
|
||||
<span className={clsx("risk-dot", city.risk_level)} />
|
||||
<span className="city-name-text">{city.display_name}</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"city-temp",
|
||||
snapshot?.current?.temp != null && "loaded",
|
||||
)}
|
||||
>
|
||||
{snapshot?.current?.temp != null
|
||||
? `${snapshot.current.temp}${snapshot.temp_symbol || "°C"}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="city-item-info">
|
||||
<span className="city-local-time">
|
||||
{snapshot?.local_time ? `🕐 ${snapshot.local_time}` : ""}
|
||||
</span>
|
||||
<span className="city-max-info">
|
||||
{detail?.current?.max_temp_time
|
||||
? `峰值 @ ${detail.current.max_temp_time}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const PolyWeatherDashboard = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/PolyWeatherDashboard").then(
|
||||
(module) => module.PolyWeatherDashboard,
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export function DashboardEntry() {
|
||||
return <PolyWeatherDashboard />;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { getCityScenery } from "@/lib/dashboard-scenery";
|
||||
import {
|
||||
getCityProfileStats,
|
||||
getClimateDrivers,
|
||||
getRiskBadgeLabel,
|
||||
getSettlementRiskNarrative,
|
||||
} from "@/lib/dashboard-utils";
|
||||
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
||||
|
||||
export function DetailPanel() {
|
||||
const store = useDashboardStore();
|
||||
const detail = store.selectedDetail;
|
||||
const isOverlayOpen =
|
||||
Boolean(store.futureModalDate) ||
|
||||
store.historyState.isOpen ||
|
||||
store.isGuideOpen;
|
||||
const isVisible =
|
||||
store.isPanelOpen &&
|
||||
Boolean(store.selectedCity) &&
|
||||
Boolean(detail) &&
|
||||
!store.loadingState.cityDetail &&
|
||||
!isOverlayOpen;
|
||||
const profileStats = detail ? getCityProfileStats(detail) : [];
|
||||
const riskLines = detail ? getSettlementRiskNarrative(detail) : [];
|
||||
const climateDrivers = detail ? getClimateDrivers(detail) : [];
|
||||
const scenery = getCityScenery(detail?.name);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={clsx("detail-panel", isVisible && "visible")}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
<div className="panel-header">
|
||||
<button
|
||||
type="button"
|
||||
className="panel-close"
|
||||
aria-label="关闭城市详情面板"
|
||||
onClick={store.closePanel}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="panel-title-area">
|
||||
<h2>{detail?.display_name?.toUpperCase() || "—"}</h2>
|
||||
<div className="panel-meta">
|
||||
<span className={clsx("risk-badge", detail?.risk?.level || "low")}>
|
||||
{getRiskBadgeLabel(detail?.risk?.level)}
|
||||
</span>
|
||||
<span className="local-time">
|
||||
{detail
|
||||
? `${detail.local_date} ${detail.local_time}`
|
||||
: "等待选择城市"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="history-btn"
|
||||
title="查看今日日内分析"
|
||||
onClick={store.openTodayModal}
|
||||
disabled={!detail}
|
||||
>
|
||||
今日日内分析
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="history-btn"
|
||||
title="查看历史对账"
|
||||
onClick={() => void store.openHistory()}
|
||||
disabled={!detail}
|
||||
>
|
||||
历史对账
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-body">
|
||||
{!detail ? (
|
||||
<section>
|
||||
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
|
||||
{store.loadingState.cityDetail
|
||||
? "正在加载城市详情..."
|
||||
: "从左侧城市列表选择一个城市查看详情。"}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="detail-scenery-card">
|
||||
{scenery ? (
|
||||
<>
|
||||
<img
|
||||
className="detail-scenery-image"
|
||||
src={scenery.imageUrl}
|
||||
alt={`${detail.display_name} 风景照`}
|
||||
/>
|
||||
<div className="detail-scenery-overlay">
|
||||
<div className="detail-scenery-copy">
|
||||
<span className="detail-scenery-kicker">
|
||||
{detail.display_name}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
className="detail-scenery-credit"
|
||||
href={scenery.creditUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{scenery.creditLabel}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="detail-scenery-fallback">
|
||||
<span className="detail-scenery-kicker">
|
||||
{detail.display_name}
|
||||
</span>
|
||||
<strong className="detail-scenery-title">
|
||||
城市风景与微气候
|
||||
</strong>
|
||||
<span className="detail-scenery-subtitle">
|
||||
当前没有匹配到风景图,仍可从下方档案与风险说明查看城市特征。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="detail-section">
|
||||
<h3>城市档案</h3>
|
||||
<div className="detail-grid">
|
||||
{profileStats.map((item) => (
|
||||
<div key={item.label} className="detail-card">
|
||||
<span className="detail-label">{item.label}</span>
|
||||
<span className="detail-value">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="detail-section">
|
||||
<h3>结算与偏差风险</h3>
|
||||
<div className="risk-info">
|
||||
{riskLines.map((line) => (
|
||||
<div key={line} className="risk-row">
|
||||
<span style={{ color: "var(--accent-cyan)", opacity: 0.6 }}>
|
||||
•
|
||||
</span>
|
||||
<span>{line}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="detail-section">
|
||||
<h3>当地气候主要受什么影响</h3>
|
||||
<div className="insight-list">
|
||||
{climateDrivers.map((driver) => (
|
||||
<div key={driver.label} className="insight-item">
|
||||
<div className="insight-title">{driver.label}</div>
|
||||
<div className="insight-text">{driver.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<ForecastTable />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
"use client";
|
||||
|
||||
import { ChartConfiguration } from "chart.js/auto";
|
||||
import clsx from "clsx";
|
||||
import { CSSProperties } from "react";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import {
|
||||
ModelForecast,
|
||||
ProbabilityDistribution,
|
||||
} from "@/components/dashboard/PanelSections";
|
||||
import {
|
||||
getFutureModalView,
|
||||
getShortTermNowcastLines,
|
||||
getTemperatureChartData,
|
||||
getWeatherSummary,
|
||||
parseAiAnalysis,
|
||||
} from "@/lib/dashboard-utils";
|
||||
|
||||
function getConfidenceLabel(confidence: string) {
|
||||
return (
|
||||
{
|
||||
high: "高",
|
||||
medium: "中",
|
||||
low: "低",
|
||||
}[confidence] || confidence
|
||||
);
|
||||
}
|
||||
|
||||
function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
|
||||
const store = useDashboardStore();
|
||||
const detail = store.selectedDetail;
|
||||
const view = detail ? getFutureModalView(detail, dateStr) : null;
|
||||
const isToday = detail ? dateStr === detail.local_date : false;
|
||||
const todayChartData = detail && isToday ? getTemperatureChartData(detail) : null;
|
||||
|
||||
const canvasRef = useChart(
|
||||
() => {
|
||||
if (!detail || !view) {
|
||||
return {
|
||||
data: { datasets: [], labels: [] },
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
if (isToday && todayChartData) {
|
||||
const datasets: NonNullable<ChartConfiguration<"line">["data"]>["datasets"] = [];
|
||||
|
||||
if (todayChartData.datasets.hasMgmHourly) {
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(234, 179, 8, 0.05)",
|
||||
borderColor: "rgba(234, 179, 8, 0.8)",
|
||||
borderWidth: 2,
|
||||
data: todayChartData.datasets.mgmHourlyPoints,
|
||||
fill: false,
|
||||
label: "MGM 预报",
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 3,
|
||||
spanGaps: true,
|
||||
tension: 0.3,
|
||||
});
|
||||
} else {
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(52, 211, 153, 0.05)",
|
||||
borderColor: "rgba(52, 211, 153, 0.6)",
|
||||
borderWidth: 1.5,
|
||||
data: todayChartData.datasets.debPast,
|
||||
fill: true,
|
||||
label: "DEB 预报",
|
||||
pointHoverRadius: 3,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
datasets.push({
|
||||
borderColor: "rgba(52, 211, 153, 0.35)",
|
||||
borderDash: [5, 3],
|
||||
borderWidth: 1.5,
|
||||
data: todayChartData.datasets.debFuture,
|
||||
fill: false,
|
||||
label: "DEB 预报",
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
datasets.push({
|
||||
backgroundColor: "#22d3ee",
|
||||
borderColor: "#22d3ee",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.metarPoints,
|
||||
fill: false,
|
||||
label: "METAR 实测",
|
||||
order: 0,
|
||||
pointHoverRadius: 7,
|
||||
pointRadius: 5,
|
||||
});
|
||||
|
||||
if (todayChartData.datasets.mgmPoints.some((value) => value != null)) {
|
||||
datasets.push({
|
||||
backgroundColor: "#facc15",
|
||||
borderColor: "#facc15",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.mgmPoints,
|
||||
fill: false,
|
||||
label: "MGM 实测",
|
||||
order: -1,
|
||||
pointHoverRadius: 9,
|
||||
pointRadius: 7,
|
||||
showLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!todayChartData.datasets.hasMgmHourly &&
|
||||
Math.abs(todayChartData.datasets.offset) > 0.3
|
||||
) {
|
||||
datasets.push({
|
||||
borderColor: "rgba(99, 102, 241, 0.2)",
|
||||
borderDash: [2, 4],
|
||||
borderWidth: 1,
|
||||
data: todayChartData.datasets.temps,
|
||||
fill: false,
|
||||
label: "OM 原始",
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets,
|
||||
labels: todayChartData.times,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: "#94a3b8",
|
||||
filter: (legendItem, chartData) => {
|
||||
const text = String(legendItem.text || "");
|
||||
if (!text) return false;
|
||||
if (!text.includes("DEB")) return true;
|
||||
|
||||
const firstDebIndex = (chartData.datasets || []).findIndex((dataset) =>
|
||||
String(dataset.label || "").includes("DEB"),
|
||||
);
|
||||
return legendItem.datasetIndex === firstDebIndex;
|
||||
},
|
||||
font: { family: "Inter", size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||||
borderColor: "rgba(34, 211, 238, 0.2)",
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
callback: (_value, index) =>
|
||||
typeof index === "number" && index % 3 === 0
|
||||
? todayChartData.times[index]
|
||||
: "",
|
||||
color: "#64748b",
|
||||
font: { family: "Inter", size: 10 },
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
max: todayChartData.max,
|
||||
min: todayChartData.min,
|
||||
ticks: {
|
||||
callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
|
||||
color: "#64748b",
|
||||
font: { family: "Inter", size: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
const labels = view.slice.map((point) => point.label);
|
||||
const unit = detail.temp_symbol || "°C";
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor: "rgba(34, 211, 238, 0.08)",
|
||||
borderColor: "#22d3ee",
|
||||
data: view.slice.map((point) => point.temp),
|
||||
fill: false,
|
||||
label: "Open-Meteo 温度",
|
||||
pointRadius: 2,
|
||||
tension: 0.28,
|
||||
},
|
||||
{
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#a78bfa",
|
||||
borderDash: [5, 4],
|
||||
data: view.slice.map((point) => point.dewPoint),
|
||||
fill: false,
|
||||
label: "露点",
|
||||
pointRadius: 0,
|
||||
tension: 0.24,
|
||||
},
|
||||
],
|
||||
labels,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: "#94a3b8",
|
||||
font: { family: "Inter", size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||||
borderColor: "rgba(34, 211, 238, 0.2)",
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: (ctx) =>
|
||||
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${unit}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
color: "#64748b",
|
||||
font: { family: "Inter", size: 10 },
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
callback: (value) => `${value}${unit}`,
|
||||
color: "#64748b",
|
||||
font: { family: "Inter", size: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
},
|
||||
[detail, isToday, todayChartData, view],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="history-chart-wrapper future-chart-wrapper">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
{isToday && (
|
||||
<div className="chart-legend">
|
||||
{todayChartData?.legendText || "暂无机场报文或小时级实测数据"}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FutureForecastModal() {
|
||||
const store = useDashboardStore();
|
||||
const detail = store.selectedDetail;
|
||||
const dateStr = store.futureModalDate;
|
||||
|
||||
if (!detail || !dateStr) return null;
|
||||
|
||||
const isToday = dateStr === detail.local_date;
|
||||
const view = getFutureModalView(detail, dateStr);
|
||||
const nowcastRows = getShortTermNowcastLines(detail, dateStr);
|
||||
const ai = parseAiAnalysis(detail.ai_analysis);
|
||||
const scorePosition = `${50 + view.front.score / 2}%`;
|
||||
const barStyle = {
|
||||
"--score-position": scorePosition,
|
||||
} as CSSProperties & { "--score-position": string };
|
||||
const weatherSummary = getWeatherSummary(detail);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="future-modal-title"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
store.closeFutureModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="modal-content large future-modal">
|
||||
<div className="modal-header">
|
||||
<h2 id="future-modal-title">
|
||||
{isToday
|
||||
? `${detail.display_name.toUpperCase()} · 今日日内分析`
|
||||
: `${detail.display_name.toUpperCase()} · ${dateStr} 未来日期分析`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
aria-label={isToday ? "关闭今日日内分析" : "关闭未来日期分析"}
|
||||
onClick={store.closeFutureModal}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body future-modal-body">
|
||||
<div className="history-stats">
|
||||
{isToday && (
|
||||
<>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">当前实测</span>
|
||||
<span className="val">
|
||||
{detail.current?.temp ?? "--"}
|
||||
{detail.temp_symbol} @{detail.current?.obs_time || "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">当前天气</span>
|
||||
<span className="val">
|
||||
{weatherSummary.weatherIcon} {weatherSummary.weatherText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">WU 结算参考</span>
|
||||
<span className="val">
|
||||
{detail.current?.wu_settlement ?? "--"}
|
||||
{detail.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">日出时间</span>
|
||||
<span className="val">{detail.forecast?.sunrise || "--"}</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">日落时间</span>
|
||||
<span className="val">{detail.forecast?.sunset || "--"}</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">日照时长</span>
|
||||
<span className="val">
|
||||
{detail.forecast?.sunshine_hours != null
|
||||
? `${detail.forecast.sunshine_hours}h`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="h-stat-card">
|
||||
<span className="label">{isToday ? "今日预报高温" : "目标日预报"}</span>
|
||||
<span className="val">
|
||||
{view.forecastEntry?.max_temp ?? "--"}
|
||||
{detail.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">DEB 预测</span>
|
||||
<span className="val">
|
||||
{view.deb ?? "--"}
|
||||
{detail.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">动态分布中心</span>
|
||||
<span className="val">
|
||||
{view.mu != null ? `${view.mu.toFixed(1)}${detail.temp_symbol}` : "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">趋势评分</span>
|
||||
<span className="val">
|
||||
{view.front.score > 0 ? "+" : ""}
|
||||
{view.front.score}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="future-modal-section">
|
||||
<h3>{isToday ? "今日温度走势" : "目标日小时走势"}</h3>
|
||||
<DailyTemperatureChart dateStr={dateStr} />
|
||||
</section>
|
||||
|
||||
<div className="future-modal-grid">
|
||||
<section className="future-modal-section">
|
||||
<h3>结算概率分布</h3>
|
||||
<ProbabilityDistribution detail={detail} targetDate={dateStr} hideTitle />
|
||||
</section>
|
||||
<section className="future-modal-section">
|
||||
<h3>多模型预报</h3>
|
||||
<ModelForecast detail={detail} targetDate={dateStr} hideTitle />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="future-modal-grid">
|
||||
<section className="future-modal-section">
|
||||
<h3>
|
||||
<span className="section-inline-icon" aria-hidden="true">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.9"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 19V5" />
|
||||
<path d="M10 19V10" />
|
||||
<path d="M16 19V7" />
|
||||
<path d="M22 19V13" />
|
||||
</svg>
|
||||
</span>
|
||||
{isToday ? "今日日内结构信号" : "未来 6-48 小时趋势"}
|
||||
</h3>
|
||||
<div className="future-front-score">
|
||||
<div className="future-front-bar" style={barStyle} />
|
||||
<div className="future-front-meta">
|
||||
<span className="future-front-pill">判断: {view.front.label}</span>
|
||||
<span className="future-front-pill">
|
||||
置信度: {getConfidenceLabel(view.front.confidence)}
|
||||
</span>
|
||||
<span className="future-front-pill">
|
||||
最大降水概率: {Math.round(view.front.precipMax)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="future-text-block">{view.front.summary}</div>
|
||||
</div>
|
||||
<div className="future-trend-grid">
|
||||
{view.front.metrics.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",
|
||||
)}
|
||||
>
|
||||
{metric.value}
|
||||
</div>
|
||||
<div className="future-trend-note">{metric.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="future-modal-section">
|
||||
<h3>AI 深度分析</h3>
|
||||
<div className="future-text-block">
|
||||
{ai.summary ? <div>{ai.summary}</div> : null}
|
||||
|
||||
{ai.bullets.length > 0 && (
|
||||
<div style={{ marginTop: ai.summary ? "10px" : 0 }}>
|
||||
{ai.bullets.map((item) => (
|
||||
<div key={item}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!ai.summary && ai.bullets.length === 0 && (
|
||||
<div>暂无 AI 分析,当前以结构化气象与模型数据为主。</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: "14px" }}>
|
||||
{nowcastRows.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<strong>{label}: </strong>
|
||||
{value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{view.front.weatherGovPeriods.length > 0 && (
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<strong>weather.gov 文本: </strong>
|
||||
{view.front.weatherGovPeriods
|
||||
.map((period) => period.short_forecast || period.detailed_forecast)
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
|
||||
const GUIDE_CARDS = [
|
||||
{
|
||||
body: "Dynamic Ensemble Blending 是系统的核心预测层。它不是对 ECMWF、GFS、ICON、GEM、JMA 等模型的简单平均,而是结合近期样本表现、当前实况与城市偏置后得到的动态加权结果。",
|
||||
title: "DEB 动态融合预测",
|
||||
},
|
||||
{
|
||||
body: "右侧的结算概率分布基于 DEB 预测值与多模型离散度动态计算。μ 代表当前分布中心,会随着模型、实况和时间变化而变化,不是固定结算值。",
|
||||
title: "结算概率引擎",
|
||||
},
|
||||
{
|
||||
body: "Polymarket 结算逻辑以机场 METAR 为主。系统优先使用 Aviation Weather API 的机场报文与原始 METAR,并区分观测时间与接收时间,避免把发布延迟误认为温度变化。",
|
||||
title: "结算点与主观测源",
|
||||
},
|
||||
{
|
||||
body: "Ankara 不走通用城市逻辑。结算主站以 LTAC / Esenboğa 为准,周边领先信号优先参考 Turkish MGM 站网,其中 Ankara (Bölge/Center) 是重点监控站,不用 Etimesgut 代替。",
|
||||
title: "Ankara 专属增强",
|
||||
},
|
||||
{
|
||||
body: "点击多日预报后的模态框,主要用于分析下一个交易日。6-48 小时趋势以 weather.gov 和 Open-Meteo 为主,部分城市补充 Meteoblue;0-2 小时临近判断优先看 METAR 与周边站。",
|
||||
title: "未来日期分析",
|
||||
},
|
||||
{
|
||||
body: "历史准确率对账只统计已结算样本。网页端采用近 15 天滚动视图,不把当天尚未结算的样本算入胜率和 MAE。",
|
||||
title: "历史对账规则",
|
||||
},
|
||||
];
|
||||
|
||||
export function GuideModal() {
|
||||
const store = useDashboardStore();
|
||||
|
||||
if (!store.isGuideOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="guide-modal-title"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
store.closeGuide();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="modal-content large">
|
||||
<div className="modal-header">
|
||||
<h2 id="guide-modal-title">📎 PolyWeather 系统技术说明</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
aria-label="关闭技术说明"
|
||||
onClick={store.closeGuide}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="guide-grid">
|
||||
{GUIDE_CARDS.map((card) => (
|
||||
<div key={card.title} className="guide-card">
|
||||
<h3>{card.title}</h3>
|
||||
<p>{card.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="guide-footer">
|
||||
数据源以 Aviation Weather / METAR、Turkish MGM、Open-Meteo、weather.gov
|
||||
为主,部分城市补充 Meteoblue。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
|
||||
export function HeaderBar() {
|
||||
const store = useDashboardStore();
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="brand">
|
||||
<h1>PolyWeather</h1>
|
||||
<span className="subtitle">天气衍生品智能分析</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="info-btn"
|
||||
title="查看系统技术说明"
|
||||
aria-label="查看系统技术说明"
|
||||
onClick={store.openGuide}
|
||||
>
|
||||
技术说明
|
||||
</button>
|
||||
<div className="live-badge" id="liveBadge">
|
||||
<span className="pulse-dot" />
|
||||
<span>实时</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("refresh-btn", store.loadingState.refresh && "spinning")}
|
||||
title="刷新所有数据"
|
||||
aria-label="刷新所有数据"
|
||||
onClick={() => void store.refreshAll()}
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { ChartConfiguration } from "chart.js/auto";
|
||||
import { useMemo } from "react";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
|
||||
import { getHistorySummary } from "@/lib/dashboard-utils";
|
||||
|
||||
function HistoryChart() {
|
||||
const store = useDashboardStore();
|
||||
const { data } = useHistoryData();
|
||||
const summary = useMemo(
|
||||
() => getHistorySummary(data, store.selectedDetail?.local_date),
|
||||
[data, store.selectedDetail?.local_date],
|
||||
);
|
||||
const hasMgm =
|
||||
store.selectedCity === "ankara" &&
|
||||
summary.mgms.some((value) => value != null);
|
||||
|
||||
const canvasRef = useChart(
|
||||
() => {
|
||||
const datasets: NonNullable<ChartConfiguration<"line">["data"]>["datasets"] = [
|
||||
{
|
||||
backgroundColor: "rgba(248, 113, 113, 0.1)",
|
||||
borderColor: "#f87171",
|
||||
borderWidth: 2,
|
||||
data: summary.actuals,
|
||||
label: "实测最高温",
|
||||
pointBackgroundColor: "#f87171",
|
||||
pointBorderColor: "#fff",
|
||||
pointRadius: 4,
|
||||
tension: 0.2,
|
||||
},
|
||||
{
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#34d399",
|
||||
borderDash: [5, 4],
|
||||
borderWidth: 2,
|
||||
data: summary.debs,
|
||||
label: "DEB 融合",
|
||||
pointRadius: 3,
|
||||
tension: 0.2,
|
||||
},
|
||||
];
|
||||
|
||||
if (hasMgm) {
|
||||
datasets.push({
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#fb923c",
|
||||
borderWidth: 2,
|
||||
data: summary.mgms,
|
||||
label: "MGM 官方预报",
|
||||
pointRadius: 3,
|
||||
tension: 0.2,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets,
|
||||
labels: summary.dates,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: "#94a3b8",
|
||||
font: { family: "Inter", size: 12 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.9)",
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: (ctx) => `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: { color: "#64748b", font: { family: "Inter", size: 10 } },
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: { color: "#64748b", font: { family: "Inter", size: 10 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
},
|
||||
[hasMgm, summary],
|
||||
);
|
||||
|
||||
if (!summary.recentData.length) return null;
|
||||
|
||||
return (
|
||||
<div className="history-chart-wrapper">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryModal() {
|
||||
const store = useDashboardStore();
|
||||
const { data, error, isLoading, isOpen } = useHistoryData();
|
||||
const summary = useMemo(
|
||||
() => getHistorySummary(data, store.selectedDetail?.local_date),
|
||||
[data, store.selectedDetail?.local_date],
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="history-modal-title"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
store.closeHistory();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h2 id="history-modal-title">
|
||||
📊 历史准确率对账 - {store.selectedCity?.toUpperCase()}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
aria-label="关闭历史对账"
|
||||
onClick={store.closeHistory}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="history-stats">
|
||||
{isLoading ? (
|
||||
<span style={{ color: "var(--text-muted)" }}>正在获取历史数据...</span>
|
||||
) : error ? (
|
||||
<span style={{ color: "var(--accent-red)" }}>获取历史信息失败</span>
|
||||
) : !summary.recentData.length ? (
|
||||
<span style={{ color: "var(--text-muted)" }}>近 15 天暂无该城市历史数据</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">DEB 结算胜率 (WU)</span>
|
||||
<span className="val">
|
||||
{summary.hitRate != null ? `${summary.hitRate}%` : "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">DEB MAE</span>
|
||||
<span className="val">
|
||||
{summary.debMae != null ? `${summary.debMae}°` : "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-stat-card">
|
||||
<span className="label">近 15 天已结算样本</span>
|
||||
<span className="val">{summary.settledCount} 天</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isLoading && !error && <HistoryChart />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useLeafletMap } from "@/hooks/useLeafletMap";
|
||||
|
||||
export function MapCanvas() {
|
||||
const store = useDashboardStore();
|
||||
const { containerRef } = useLeafletMap({
|
||||
cities: store.cities,
|
||||
cityDetailsByName: store.cityDetailsByName,
|
||||
citySummariesByName: store.citySummariesByName,
|
||||
onClosePanel: store.closePanel,
|
||||
onEnsureCityDetail: store.ensureCityDetail,
|
||||
onRegisterStopMotion: store.registerMapStopMotion,
|
||||
onSelectCity: (cityName) => {
|
||||
void store.selectCity(cityName);
|
||||
},
|
||||
selectedCity: store.selectedCity,
|
||||
selectedDetail: store.selectedDetail,
|
||||
suspendMotion:
|
||||
Boolean(store.futureModalDate) ||
|
||||
store.historyState.isOpen ||
|
||||
store.isGuideOpen,
|
||||
isLoadingDetail: store.loadingState.cityDetail,
|
||||
});
|
||||
|
||||
return <div ref={containerRef} className="map" />;
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
"use client";
|
||||
|
||||
import { ChartConfiguration } from "chart.js/auto";
|
||||
import clsx from "clsx";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { CityDetail } from "@/lib/dashboard-types";
|
||||
import {
|
||||
getHeroMetaItems,
|
||||
getModelView,
|
||||
getProbabilityView,
|
||||
getTemperatureChartData,
|
||||
getWeatherSummary,
|
||||
parseAiAnalysis,
|
||||
} from "@/lib/dashboard-utils";
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return <div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>;
|
||||
}
|
||||
|
||||
export function HeroSummary() {
|
||||
const { data } = useCityData();
|
||||
if (!data) return null;
|
||||
|
||||
const { weatherIcon, weatherText } = getWeatherSummary(data);
|
||||
const metaItems = getHeroMetaItems(data);
|
||||
const current = data.current || {};
|
||||
const isMax =
|
||||
current.max_so_far != null &&
|
||||
current.temp != null &&
|
||||
current.max_so_far <= current.temp;
|
||||
|
||||
return (
|
||||
<section className="hero-section">
|
||||
<div className="hero-weather">
|
||||
<span>
|
||||
{weatherIcon} {weatherText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hero-temp">
|
||||
<span className="hero-value">
|
||||
{current.temp != null ? current.temp.toFixed(1) : "--"}
|
||||
</span>
|
||||
<span className="hero-unit">{data.temp_symbol || "°C"}</span>
|
||||
</div>
|
||||
<div className="hero-max-time">
|
||||
{isMax && current.max_temp_time
|
||||
? `该城市今日最高温出现在当地时间 ${current.max_temp_time}`
|
||||
: ""}
|
||||
</div>
|
||||
<div className="hero-details">
|
||||
<div className="hero-item">
|
||||
<span className="label">当前实测</span>
|
||||
<span className="value">
|
||||
{current.temp != null
|
||||
? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hero-item">
|
||||
<span className="label">WU 结算参考</span>
|
||||
<span className="value highlight">
|
||||
{current.wu_settlement != null
|
||||
? `${current.wu_settlement}${data.temp_symbol}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hero-item">
|
||||
<span className="label">DEB 预测</span>
|
||||
<span className="value">
|
||||
{data.deb?.prediction != null
|
||||
? `${data.deb.prediction}${data.temp_symbol}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-sub">
|
||||
{metaItems.map((item) => (
|
||||
<span key={item}>{item}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function TemperatureChart() {
|
||||
const { data } = useCityData();
|
||||
const chartData = data ? getTemperatureChartData(data) : null;
|
||||
|
||||
const canvasRef = useChart(
|
||||
() => {
|
||||
if (!data || !chartData) {
|
||||
return {
|
||||
data: { datasets: [], labels: [] },
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
const datasets: NonNullable<ChartConfiguration<"line">["data"]>["datasets"] = [];
|
||||
|
||||
if (chartData.datasets.hasMgmHourly) {
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(234, 179, 8, 0.05)",
|
||||
borderColor: "rgba(234, 179, 8, 0.8)",
|
||||
borderWidth: 2,
|
||||
data: chartData.datasets.mgmHourlyPoints,
|
||||
fill: false,
|
||||
label: "MGM 预报",
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 3,
|
||||
spanGaps: true,
|
||||
tension: 0.3,
|
||||
});
|
||||
} else {
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(52, 211, 153, 0.05)",
|
||||
borderColor: "rgba(52, 211, 153, 0.6)",
|
||||
borderWidth: 1.5,
|
||||
data: chartData.datasets.debPast,
|
||||
fill: true,
|
||||
label: "DEB 预报",
|
||||
pointHoverRadius: 3,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
datasets.push({
|
||||
borderColor: "rgba(52, 211, 153, 0.35)",
|
||||
borderDash: [5, 3],
|
||||
borderWidth: 1.5,
|
||||
data: chartData.datasets.debFuture,
|
||||
fill: false,
|
||||
label: "DEB 预报",
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
datasets.push({
|
||||
backgroundColor: "#22d3ee",
|
||||
borderColor: "#22d3ee",
|
||||
borderWidth: 0,
|
||||
data: chartData.datasets.metarPoints,
|
||||
fill: false,
|
||||
label: "METAR 实测",
|
||||
order: 0,
|
||||
pointHoverRadius: 7,
|
||||
pointRadius: 5,
|
||||
});
|
||||
|
||||
if (chartData.datasets.mgmPoints.some((value) => value != null)) {
|
||||
datasets.push({
|
||||
backgroundColor: "#facc15",
|
||||
borderColor: "#facc15",
|
||||
borderWidth: 0,
|
||||
data: chartData.datasets.mgmPoints,
|
||||
fill: false,
|
||||
label: "MGM 实测",
|
||||
order: -1,
|
||||
pointHoverRadius: 9,
|
||||
pointRadius: 7,
|
||||
showLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!chartData.datasets.hasMgmHourly &&
|
||||
Math.abs(chartData.datasets.offset) > 0.3
|
||||
) {
|
||||
datasets.push({
|
||||
borderColor: "rgba(99, 102, 241, 0.2)",
|
||||
borderDash: [2, 4],
|
||||
borderWidth: 1,
|
||||
data: chartData.datasets.temps,
|
||||
fill: false,
|
||||
label: "OM 原始",
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets,
|
||||
labels: chartData.times,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.9)",
|
||||
borderColor: "rgba(52, 211, 153, 0.3)",
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
callback: (_value, index) =>
|
||||
typeof index === "number" && index % 3 === 0
|
||||
? chartData.times[index]
|
||||
: "",
|
||||
color: "#64748b",
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
max: chartData.max,
|
||||
min: chartData.min,
|
||||
ticks: {
|
||||
callback: (value) => `${value}${data.temp_symbol || "°C"}`,
|
||||
color: "#64748b",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
},
|
||||
[data, chartData],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="chart-section">
|
||||
<h3>今日温度走势</h3>
|
||||
<div className="chart-wrapper">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
<div className="chart-legend">{chartData?.legendText || "暂无小时级数据"}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProbabilityDistribution({
|
||||
detail,
|
||||
hideTitle = false,
|
||||
targetDate,
|
||||
}: {
|
||||
detail: CityDetail;
|
||||
hideTitle?: boolean;
|
||||
targetDate?: string | null;
|
||||
}) {
|
||||
const view = getProbabilityView(detail, targetDate);
|
||||
|
||||
return (
|
||||
<section className="prob-section">
|
||||
{!hideTitle && <h3>结算概率分布</h3>}
|
||||
<div className="prob-bars">
|
||||
{view.mu != null && (
|
||||
<div
|
||||
style={{ color: "var(--text-muted)", fontSize: "11px", marginBottom: "6px" }}
|
||||
>
|
||||
动态分布中心 μ = {view.mu.toFixed(1)}
|
||||
{detail.temp_symbol}
|
||||
</div>
|
||||
)}
|
||||
{view.probabilities.length === 0 ? (
|
||||
<EmptyState text="暂无概率数据" />
|
||||
) : (
|
||||
view.probabilities.slice(0, 6).map((bucket, index) => {
|
||||
const probability = Math.round(Number(bucket.probability || 0) * 100);
|
||||
return (
|
||||
<div key={`${bucket.label || bucket.value || index}`} className="prob-row">
|
||||
<div className="prob-label">
|
||||
{bucket.label || `${bucket.value}${detail.temp_symbol}`}
|
||||
</div>
|
||||
<div className="prob-bar-track">
|
||||
<div
|
||||
className={clsx("prob-bar-fill", `rank-${index}`)}
|
||||
style={{ width: `${Math.max(probability, 8)}%` }}
|
||||
>
|
||||
{probability}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelForecast({
|
||||
detail,
|
||||
hideTitle = false,
|
||||
targetDate,
|
||||
}: {
|
||||
detail: CityDetail;
|
||||
hideTitle?: boolean;
|
||||
targetDate?: string | null;
|
||||
}) {
|
||||
const view = getModelView(detail, targetDate);
|
||||
const modelEntries = Object.entries(view.models).filter(([, value]) =>
|
||||
Number.isFinite(Number(value)),
|
||||
);
|
||||
const numericValues = modelEntries.map(([, value]) => Number(value));
|
||||
const comparisonValues =
|
||||
view.deb != null ? [...numericValues, Number(view.deb)] : numericValues;
|
||||
const minValue = comparisonValues.length ? Math.min(...comparisonValues) - 1 : 0;
|
||||
const maxValue = comparisonValues.length ? Math.max(...comparisonValues) + 1 : 1;
|
||||
const range = Math.max(maxValue - minValue, 1);
|
||||
|
||||
return (
|
||||
<section className="models-section">
|
||||
{!hideTitle && <h3>多模型预报</h3>}
|
||||
<div className="model-bars">
|
||||
{!modelEntries.length ? (
|
||||
<EmptyState text="暂无多模型预报" />
|
||||
) : (
|
||||
<>
|
||||
{modelEntries
|
||||
.sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0))
|
||||
.map(([name, value]) => {
|
||||
const numeric = Number(value);
|
||||
const width = ((numeric - minValue) / range) * 100;
|
||||
const debLine =
|
||||
view.deb != null
|
||||
? ((Number(view.deb) - minValue) / range) * 100
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={name} className="model-row">
|
||||
<div className="model-name" title={name}>
|
||||
{name}
|
||||
</div>
|
||||
<div className="model-bar-track">
|
||||
<div className="model-bar-fill" style={{ width: `${width}%` }}>
|
||||
{numeric}
|
||||
{detail.temp_symbol}
|
||||
</div>
|
||||
{debLine != null && (
|
||||
<div className="model-deb-line" style={{ left: `${debLine}%` }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{view.deb != null && (
|
||||
<div
|
||||
className="model-row"
|
||||
style={{
|
||||
borderTop: "1px solid rgba(255,255,255,0.06)",
|
||||
marginTop: "6px",
|
||||
paddingTop: "6px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="model-name"
|
||||
style={{ color: "var(--accent-cyan)", fontWeight: 700 }}
|
||||
>
|
||||
DEB
|
||||
</div>
|
||||
<div className="model-bar-track">
|
||||
<div
|
||||
className="model-bar-fill deb"
|
||||
style={{
|
||||
width: `${((Number(view.deb) - minValue) / range) * 100}%`,
|
||||
}}
|
||||
>
|
||||
{Number(view.deb)}
|
||||
{detail.temp_symbol}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForecastTable() {
|
||||
const store = useDashboardStore();
|
||||
const { data } = useCityData();
|
||||
if (!data) return null;
|
||||
|
||||
const daily = data.forecast?.daily || [];
|
||||
return (
|
||||
<section className="forecast-section">
|
||||
<h3>多日预报</h3>
|
||||
<div className="forecast-table">
|
||||
{daily.length === 0 ? (
|
||||
<EmptyState text="暂无多日预报" />
|
||||
) : (
|
||||
daily.map((day, index) => {
|
||||
const isToday = day.date === data.local_date || index === 0;
|
||||
const isSelected =
|
||||
store.futureModalDate === day.date ||
|
||||
store.selectedForecastDate === day.date;
|
||||
return (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
className={clsx("forecast-day", isToday && "today", isSelected && "selected")}
|
||||
onClick={() => {
|
||||
store.openFutureModal(day.date);
|
||||
}}
|
||||
>
|
||||
<div className="f-date">
|
||||
{isToday ? "今天" : day.date.substring(5).replace("-", "/")}
|
||||
</div>
|
||||
<div className="f-temp">
|
||||
{day.max_temp}
|
||||
{data.temp_symbol}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiAnalysis() {
|
||||
const { data } = useCityData();
|
||||
if (!data) return null;
|
||||
const ai = parseAiAnalysis(data.ai_analysis);
|
||||
|
||||
return (
|
||||
<section className="ai-section">
|
||||
<h3>AI 深度分析</h3>
|
||||
<div className="ai-box">
|
||||
{!ai.summary && ai.bullets.length === 0 ? (
|
||||
<span className="ai-placeholder">
|
||||
暂无 AI 分析,当前以结构化气象与模型数据为主。
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{ai.summary && <div className="ai-summary">{ai.summary}</div>}
|
||||
{ai.bullets.length > 0 && (
|
||||
<ul className="ai-list">
|
||||
{ai.bullets.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function RiskInfo() {
|
||||
const { data } = useCityData();
|
||||
if (!data) return null;
|
||||
const risk = data.risk || {};
|
||||
|
||||
return (
|
||||
<section className="risk-section">
|
||||
<h3>数据偏差风险</h3>
|
||||
<div className="risk-info">
|
||||
{!risk.airport ? (
|
||||
<span style={{ color: "var(--text-muted)" }}>暂无风险档案</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="risk-row">
|
||||
<span className="risk-label">机场</span>
|
||||
<span>
|
||||
{risk.airport} ({risk.icao})
|
||||
</span>
|
||||
</div>
|
||||
<div className="risk-row">
|
||||
<span className="risk-label">距离</span>
|
||||
<span>{risk.distance_km}km</span>
|
||||
</div>
|
||||
{risk.warning && (
|
||||
<div className="risk-row">
|
||||
<span className="risk-label">注意</span>
|
||||
<span>{risk.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import styles from "./Dashboard.module.css";
|
||||
import {
|
||||
DashboardStoreProvider,
|
||||
useDashboardStore,
|
||||
} from "@/hooks/useDashboardStore";
|
||||
import { CitySidebar } from "@/components/dashboard/CitySidebar";
|
||||
import { DetailPanel } from "@/components/dashboard/DetailPanel";
|
||||
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
|
||||
import { GuideModal } from "@/components/dashboard/GuideModal";
|
||||
import { HeaderBar } from "@/components/dashboard/HeaderBar";
|
||||
import { HistoryModal } from "@/components/dashboard/HistoryModal";
|
||||
import { MapCanvas } from "@/components/dashboard/MapCanvas";
|
||||
|
||||
function DashboardScreen() {
|
||||
const store = useDashboardStore();
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (store.futureModalDate) {
|
||||
store.closeFutureModal();
|
||||
return;
|
||||
}
|
||||
if (store.historyState.isOpen) {
|
||||
store.closeHistory();
|
||||
return;
|
||||
}
|
||||
if (store.isGuideOpen) {
|
||||
store.closeGuide();
|
||||
return;
|
||||
}
|
||||
if (store.isPanelOpen) {
|
||||
store.closePanel();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [store]);
|
||||
|
||||
// Avoid full-page flashing on initial load; only show this overlay for manual refresh.
|
||||
const showLoading =
|
||||
store.loadingState.cities ||
|
||||
store.loadingState.cityDetail ||
|
||||
store.loadingState.refresh;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<MapCanvas />
|
||||
<HeaderBar />
|
||||
<CitySidebar />
|
||||
<DetailPanel />
|
||||
<GuideModal />
|
||||
<HistoryModal />
|
||||
<FutureForecastModal />
|
||||
{showLoading && (
|
||||
<div className="loading-overlay">
|
||||
<div className="loading-spinner" />
|
||||
<span>正在获取气象数据,请稍候...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PolyWeatherDashboard() {
|
||||
return (
|
||||
<DashboardStoreProvider>
|
||||
<DashboardScreen />
|
||||
</DashboardStoreProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
BarChart2,
|
||||
Target,
|
||||
ShieldAlert,
|
||||
Zap,
|
||||
Info,
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Sparkline } from "./Sparkline";
|
||||
import { CityDetail } from "../../lib/types";
|
||||
|
||||
interface AnalyticsPanelProps {
|
||||
data: CityDetail;
|
||||
t?: any; // Optional translations
|
||||
}
|
||||
|
||||
export function AnalyticsPanel({
|
||||
data,
|
||||
t = {}, // Default empty for now, can be expanded via context or props
|
||||
}: AnalyticsPanelProps) {
|
||||
const { overview, market_scan, models, ai_analysis } = data;
|
||||
|
||||
const modelEntries = Object.entries(models)
|
||||
.filter(([_, v]) => v !== undefined && v !== null)
|
||||
.map(([label, value]) => ({ label, value: value as number }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
const signalColor =
|
||||
market_scan.signal_label === "BUY YES"
|
||||
? "border-emerald-500/30 bg-emerald-500/5 text-emerald-400"
|
||||
: market_scan.signal_label === "BUY NO"
|
||||
? "border-rose-500/30 bg-rose-500/5 text-rose-400"
|
||||
: "border-zinc-800 bg-zinc-900/50 text-zinc-400";
|
||||
|
||||
const signalIconColor =
|
||||
market_scan.signal_label === "BUY YES"
|
||||
? "text-emerald-500"
|
||||
: market_scan.signal_label === "BUY NO"
|
||||
? "text-rose-500"
|
||||
: "text-zinc-500";
|
||||
|
||||
return (
|
||||
<div className="flex relative h-full w-[400px] flex-col bg-zinc-950/20 backdrop-blur-sm border-l border-zinc-800/50 overflow-hidden">
|
||||
{/* Header Stat Area */}
|
||||
<div className="p-4 border-b border-zinc-800 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">
|
||||
{t.marketAnalysis || "MARKET ANALYSIS"}
|
||||
</h2>
|
||||
<Badge className="bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 border-cyan-500/30 text-[9px] h-5">
|
||||
{t.realTime || "LIVE UPLINK"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-3 ring-1 ring-inset ring-white/5">
|
||||
<span className="text-[9px] font-bold text-zinc-500 uppercase tracking-tighter">
|
||||
{t.liveMetar || "REAL-TIME METAR"}
|
||||
</span>
|
||||
<div className="mt-1 flex items-baseline gap-1">
|
||||
<span className="text-xl font-black text-white">
|
||||
{overview.current_temp?.toFixed(1) || "--"}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-zinc-500">
|
||||
{overview.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-3 ring-1 ring-inset ring-white/5">
|
||||
<span className="text-[9px] font-bold text-zinc-500 uppercase tracking-tighter">
|
||||
{t.ensembleDeb || "ENSEMBLE DEB"}
|
||||
</span>
|
||||
<div className="mt-1 flex items-baseline gap-1">
|
||||
<span className="text-xl font-black text-cyan-400">
|
||||
{overview.deb_prediction?.toFixed(1) || "--"}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-cyan-500">
|
||||
{overview.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade Execution Signal */}
|
||||
<div
|
||||
className={`rounded-xl border p-4 transition-all duration-500 ${signalColor}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tighter">
|
||||
{t.signalStrategy || "SIGNAL STRATEGY"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={`h-4 w-4 ${market_scan.available ? "animate-pulse" : ""} ${signalIconColor}`}
|
||||
/>
|
||||
<span className="text-sm font-black text-white italic uppercase tracking-wider">
|
||||
{market_scan.signal_label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tighter">
|
||||
{t.confidence || "CONFIDENCE"}
|
||||
</span>
|
||||
<div className="mt-1 text-xs font-black text-white opacity-80 uppercase tracking-widest">
|
||||
{market_scan.confidence}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-10 mt-2 flex items-end">
|
||||
<Sparkline
|
||||
data={
|
||||
market_scan.sparkline.length > 0
|
||||
? market_scan.sparkline
|
||||
: [1, 1, 1]
|
||||
}
|
||||
width={360}
|
||||
height={35}
|
||||
color={
|
||||
market_scan.signal_label === "BUY YES"
|
||||
? "#10b981"
|
||||
: market_scan.signal_label === "BUY NO"
|
||||
? "#f43f5e"
|
||||
: "#52525b"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Probability & Market Pricing */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6 custom-scrollbar pb-20">
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Target className="h-3 w-3 text-violet-400" />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-zinc-400">
|
||||
{t.yieldArbitrage || "YIELD ARBITRAGE"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[10px] uppercase font-bold text-zinc-500">
|
||||
<span>{t.probComparison || "PROB COMPARISON"}</span>
|
||||
<span className="text-cyan-400">
|
||||
{t.predictionGap || "GAP"}:{" "}
|
||||
{market_scan.edge_percent?.toFixed(1) || "0.0"}%
|
||||
</span>
|
||||
</div>
|
||||
{/* Visual Probability Bar */}
|
||||
<div className="relative h-4 w-full bg-zinc-900 rounded-sm border border-zinc-800 overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full bg-zinc-800 border-r border-zinc-700 transition-all duration-700"
|
||||
style={{ width: `${(market_scan.market_price || 0) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute h-full bg-violet-500/40 border-r border-violet-400 shadow-[0_0_8px_rgba(167,139,250,0.5)] transition-all duration-1000"
|
||||
style={{
|
||||
width: `${(market_scan.model_probability || 0) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col rounded-md bg-zinc-900/40 px-3 py-2 border border-zinc-800">
|
||||
<span className="text-[9px] text-zinc-500 uppercase font-black">
|
||||
{t.marketPriceLabel || "MIDPOINT"}
|
||||
</span>
|
||||
<span className="font-mono text-zinc-100 font-bold text-sm">
|
||||
${market_scan.market_price?.toFixed(2) || "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-md bg-zinc-900/40 px-3 py-2 border border-zinc-800">
|
||||
<span className="text-[9px] text-violet-500 uppercase font-black">
|
||||
{t.modelProbLabel || "MODEL PROB"}
|
||||
</span>
|
||||
<span className="font-mono text-violet-400 font-bold text-sm">
|
||||
{((market_scan.model_probability || 0) * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BarChart2 className="h-3 w-3 text-cyan-400" />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-zinc-400">
|
||||
{t.multiModelDivergence || "MULTI-MODEL DIVERGENCE"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{modelEntries.map((m) => (
|
||||
<div key={m.label} className="group flex flex-col gap-1 px-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-bold text-zinc-500 group-hover:text-zinc-300 transition-colors uppercase">
|
||||
{m.label}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono font-bold text-white text-right">
|
||||
{m.value.toFixed(1)}°
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-1 w-full bg-zinc-800/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${
|
||||
m.value > 25
|
||||
? "bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.5)]"
|
||||
: m.value > 15
|
||||
? "bg-amber-500"
|
||||
: "bg-cyan-500 shadow-[0_0_8px_rgba(34,211,238,0.5)]"
|
||||
}`}
|
||||
style={{ width: `${(m.value / 40) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{ai_analysis && (
|
||||
<section className="pt-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Activity className="h-3 w-3 text-amber-500" />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-zinc-400">
|
||||
AI COGNITIVE ANALYSIS
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-2 opacity-20">
|
||||
<ShieldAlert className="w-8 h-8 text-amber-500" />
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-zinc-400 relative z-10 font-medium">
|
||||
{ai_analysis}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Execute Scan Footer */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 bg-zinc-950/80 backdrop-blur-md border-t border-zinc-800/50">
|
||||
<button className="w-full rounded-lg bg-cyan-500 py-3 text-[11px] font-black uppercase tracking-widest text-black shadow-[0_0_20px_rgba(34,211,238,0.3)] transition-all hover:bg-cyan-400 hover:scale-[0.98] active:scale-95 disabled:opacity-50 disabled:pointer-events-none">
|
||||
EXECUTE MARKET RE-SCAN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from "react";
|
||||
import { Search, Globe, ChevronRight, Activity } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CityInfo } from "@/lib/types";
|
||||
import { Sparkline } from "./Sparkline";
|
||||
|
||||
interface CitySidebarProps {
|
||||
cities: CityInfo[];
|
||||
activeCity: string;
|
||||
onSelectCity: (name: string) => void;
|
||||
t: any;
|
||||
}
|
||||
|
||||
export function CitySidebar({
|
||||
cities,
|
||||
activeCity,
|
||||
onSelectCity,
|
||||
t,
|
||||
}: CitySidebarProps) {
|
||||
return (
|
||||
<aside className="flex h-full w-[300px] flex-col border-r border-terminal-border bg-terminal/95 transition-all">
|
||||
{/* Search Header */}
|
||||
<div className="p-4 border-b border-terminal-border">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500 transition-colors group-focus-within:text-neon-cyan" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t.lookupPlaceholder || "Search Terminal..."}
|
||||
className="w-full rounded-lg border border-terminal-border bg-slate-900/50 py-2 pl-9 pr-4 text-xs text-white placeholder:text-slate-600 focus:border-neon-cyan/50 focus:outline-none focus:ring-1 focus:ring-neon-cyan/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monitor List */}
|
||||
<div className="flex-1 overflow-y-auto px-2 py-4 custom-scrollbar">
|
||||
<div className="mb-4 flex items-center justify-between px-3">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">
|
||||
{t.cityMonitor || "MONITORING NODES"}
|
||||
</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 rounded-md border-terminal-border text-[10px] text-slate-400"
|
||||
>
|
||||
{cities.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{cities.map((city) => {
|
||||
const isActive =
|
||||
activeCity.toLowerCase() === city.name.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={city.name}
|
||||
onClick={() => onSelectCity(city.name)}
|
||||
className={`group relative flex w-full items-center gap-3 rounded-lg px-3 py-2.5 transition-all ${
|
||||
isActive
|
||||
? "bg-neon-cyan/10 ring-1 ring-neon-cyan/30"
|
||||
: "hover:bg-slate-900/50"
|
||||
}`}
|
||||
>
|
||||
{/* Active Indicator Bar */}
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 h-4 w-1 -translate-y-1/2 rounded-r bg-neon-cyan shadow-[0_0_8px_rgba(34,211,238,0.8)]" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col items-start overflow-hidden">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span
|
||||
className={`truncate text-[11px] font-bold uppercase tracking-wider ${isActive ? "text-neon-cyan" : "text-slate-200"}`}
|
||||
>
|
||||
{city.display_name}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-slate-500">
|
||||
{city.icao}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
city.risk_level === "high"
|
||||
? "bg-rose-500 shadow-[0_0_4px_rgba(244,63,94,0.6)]"
|
||||
: city.risk_level === "medium"
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate text-[10px] text-slate-500 font-medium">
|
||||
{city.airport.replace("Airport", "Intl")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 px-1">
|
||||
<Sparkline
|
||||
data={[10, 12, 11, 13, 12]}
|
||||
width={40}
|
||||
height={15}
|
||||
strokeWidth={1.5}
|
||||
color="#475569"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 transition-transform ${isActive ? "translate-x-0.5 text-neon-cyan" : "text-slate-700 opacity-0 group-hover:opacity-100"}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Status */}
|
||||
<div className="border-t border-terminal-border bg-slate-950 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-500">
|
||||
UPLINK STATUS
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500 shadow-[0_0_4px_rgba(16,185,129,0.8)]" />
|
||||
<span className="text-[10px] font-bold text-emerald-500">LIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-terminal-border bg-slate-900/50 p-2">
|
||||
<div className="flex items-center gap-2 text-[9px] font-mono text-slate-400">
|
||||
<Activity className="h-3 w-3 text-neon-cyan" />
|
||||
<span>LATENCY: 12ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[];
|
||||
color?: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
strokeWidth?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Sparkline({
|
||||
data,
|
||||
color = "#22d3ee",
|
||||
height = 40,
|
||||
width = 120,
|
||||
strokeWidth = 2,
|
||||
className = "",
|
||||
}: SparklineProps) {
|
||||
if (!data || data.length < 2) return <div style={{ width, height }} />;
|
||||
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const range = max - min || 1;
|
||||
|
||||
const points = data
|
||||
.map((val, i) => {
|
||||
const x = (i / (data.length - 1)) * width;
|
||||
const y = height - ((val - min) / range) * height;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<defs>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
points={points}
|
||||
filter="url(#glow)"
|
||||
style={{ opacity: 0.8 }}
|
||||
/>
|
||||
{/* Fill Area */}
|
||||
<path
|
||||
d={`M 0 ${height} L ${points} L ${width} ${height} Z`}
|
||||
fill={`url(#gradient-${color.replace("#", "")})`}
|
||||
style={{ opacity: 0.1 }}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`gradient-${color.replace("#", "")}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} />
|
||||
<stop offset="100%" stopColor="transparent" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { CitySidebar } from "./Sidebar";
|
||||
import { AnalyticsPanel } from "./AnalyticsPanel";
|
||||
import {
|
||||
RefreshCw,
|
||||
LayoutGrid,
|
||||
Maximize2,
|
||||
Layers,
|
||||
Languages,
|
||||
Sun,
|
||||
Moon,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface TerminalDashboardProps {
|
||||
cities: any[];
|
||||
activeCity: string;
|
||||
onSelectCity: (name: string) => void;
|
||||
cityData: any;
|
||||
marketData: any;
|
||||
officialData: any;
|
||||
isLoading: boolean;
|
||||
refresh: () => void;
|
||||
lang: any;
|
||||
setLang: (lang: any) => void;
|
||||
theme: string;
|
||||
toggleTheme: () => void;
|
||||
t: any;
|
||||
children: React.ReactNode; // For the Map
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
label,
|
||||
active = false,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={`text-[10px] font-black uppercase tracking-widest transition-colors ${
|
||||
active
|
||||
? "text-neon-cyan shadow-[0_0_10px_rgba(34,211,238,0.3)]"
|
||||
: "text-slate-500 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalDashboard({
|
||||
cities,
|
||||
activeCity,
|
||||
onSelectCity,
|
||||
cityData,
|
||||
marketData,
|
||||
officialData,
|
||||
isLoading,
|
||||
refresh,
|
||||
lang,
|
||||
setLang,
|
||||
theme,
|
||||
toggleTheme,
|
||||
t,
|
||||
children,
|
||||
}: TerminalDashboardProps) {
|
||||
const currentTemp = cityData?.current?.temp ?? null;
|
||||
const debPrediction = cityData?.deb?.prediction ?? null;
|
||||
const marketPrice = marketData?.markets?.[0]?.price ?? 0.62; // Placeholder
|
||||
|
||||
const modelEntries = Object.entries(cityData?.multi_model ?? {})
|
||||
.map(([label, value]) => ({ label, value: value as number }))
|
||||
.filter((e) => typeof e.value === "number");
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col overflow-hidden bg-terminal font-sans text-slate-200 antialiased">
|
||||
{/* Top Global Ticker / Nav */}
|
||||
<div className="flex h-14 items-center justify-between border-b border-terminal-border bg-slate-950/50 px-6 backdrop-blur-md">
|
||||
<div className="flex items-center gap-6">
|
||||
<NavLink href="#" label={t.marketScan} active />
|
||||
<NavLink href="#" label="FORENSICS" />
|
||||
<NavLink href="#" label="ARBITRAGE" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 rounded-full bg-slate-900 px-3 py-1 border border-terminal-border">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">
|
||||
{t.node || "Node"}:
|
||||
</span>
|
||||
<span className="text-[10px] font-mono font-bold text-neon-cyan tracking-widest">
|
||||
CLOUD-ALPHA-01
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-[10px] font-bold text-slate-400 hover:bg-white/5 hover:text-white transition-all border border-transparent hover:border-terminal-border"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="uppercase tracking-widest">{t.refresh}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center border-r border-white/10 pr-3 mr-1 gap-1">
|
||||
<button
|
||||
onClick={() => setLang(lang === "en" ? "zh" : "en")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 hover:bg-white/5 hover:text-neon-cyan transition-all"
|
||||
title={lang === "en" ? "Switch to Chinese" : "切换为英文"}
|
||||
>
|
||||
<Languages className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 hover:bg-white/5 hover:text-neon-cyan transition-all"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-6 border-terminal-border bg-slate-900/50 text-[10px] font-mono text-slate-400"
|
||||
>
|
||||
NODE: CLOUD-ALPHA-01
|
||||
</Badge>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="flex h-8 items-center gap-2 rounded-md border border-terminal-border bg-slate-900/50 px-3 transition-all hover:bg-slate-800"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 text-slate-400 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="text-[10px] font-bold text-slate-300">
|
||||
REFRESH
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Container */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left: Component List */}
|
||||
<CitySidebar
|
||||
cities={cities}
|
||||
activeCity={activeCity}
|
||||
onSelectCity={onSelectCity}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Center: Interactive Map Area */}
|
||||
<div className="relative flex-1 bg-slate-950 overflow-hidden flex flex-col">
|
||||
{/* Map UI Overlay */}
|
||||
<div className="absolute left-4 top-4 z-10 flex flex-col gap-2">
|
||||
<div className="rounded-lg border border-terminal-border bg-terminal/80 p-2 backdrop-blur-md shadow-2xl">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[9px] font-bold text-slate-500 uppercase">
|
||||
Current Focus
|
||||
</span>
|
||||
<span className="text-xs font-black text-white uppercase tracking-widest">
|
||||
{activeCity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-4 top-4 z-10 flex flex-col gap-2">
|
||||
<button className="rounded-md border border-terminal-border bg-terminal/80 p-2 text-slate-400 hover:text-white backdrop-blur-md">
|
||||
<Layers className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="rounded-md border border-terminal-border bg-terminal/80 p-2 text-slate-400 hover:text-white backdrop-blur-md">
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* The Map */}
|
||||
<div className="flex-1 h-full min-h-0 relative bg-zinc-900/10">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Bottom Map Info Bar */}
|
||||
<div className="absolute bottom-4 left-4 right-4 z-10 flex items-center justify-between pointer-events-none">
|
||||
<div className="rounded-md border border-terminal-border bg-terminal/80 px-3 py-1.5 backdrop-blur-md pointer-events-auto">
|
||||
<span className="text-[9px] font-bold text-slate-500 uppercase tracking-widest">
|
||||
Projection: Mercator · Layer: Dynamic Thermal Ensemble
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 pointer-events-auto">
|
||||
<Badge className="bg-emerald-500/10 text-emerald-500 border-emerald-500/20">
|
||||
STABLE VIBE
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Intelligence Panel */}
|
||||
<AnalyticsPanel
|
||||
data={
|
||||
{
|
||||
city: activeCity,
|
||||
fetched_at: cityData?.updated_at || "",
|
||||
overview: {
|
||||
name: activeCity,
|
||||
display_name: cityData?.display_name || activeCity,
|
||||
icao: officialData?.aviation_weather?.icao || "",
|
||||
airport: cityData?.risk?.airport || "",
|
||||
lat: cityData?.lat || 0,
|
||||
lon: cityData?.lon || 0,
|
||||
local_time: cityData?.local_time || "",
|
||||
local_date: cityData?.local_date || "",
|
||||
temp_symbol: cityData?.temp_symbol || "°C",
|
||||
current_temp: currentTemp,
|
||||
deb_prediction: debPrediction,
|
||||
risk_level: cityData?.risk?.level || "low",
|
||||
risk_warning: cityData?.risk?.warning || "",
|
||||
updated_at: cityData?.updated_at || "",
|
||||
},
|
||||
official: {
|
||||
available: !!officialData?.aviation_weather?.available,
|
||||
metar: officialData?.aviation_weather?.observation,
|
||||
weather_gov: officialData?.weather_gov || {},
|
||||
mgm: cityData?.mgm || {},
|
||||
mgm_nearby: [],
|
||||
},
|
||||
timeseries: {
|
||||
metar_recent_obs: [],
|
||||
metar_today_obs: [],
|
||||
hourly: {},
|
||||
mgm_hourly: [],
|
||||
forecast_daily: cityData?.forecast?.multi_day || [],
|
||||
},
|
||||
models: cityData?.multi_model || {},
|
||||
probabilities: cityData?.probabilities || {
|
||||
mu: null,
|
||||
distribution: [],
|
||||
},
|
||||
market_scan: {
|
||||
available: !!marketData?.markets?.length,
|
||||
reason: null,
|
||||
primary_market: marketData?.markets?.[0] || null,
|
||||
selected_date: marketData?.target_date || null,
|
||||
selected_condition_id: null,
|
||||
selected_slug: null,
|
||||
temperature_bucket: null,
|
||||
model_probability: null,
|
||||
market_price: marketPrice,
|
||||
edge_percent: null,
|
||||
signal_label: "MONITOR",
|
||||
confidence: "low",
|
||||
yes_token: null,
|
||||
no_token: null,
|
||||
yes_buy: null,
|
||||
yes_sell: null,
|
||||
no_buy: null,
|
||||
no_sell: null,
|
||||
last_trade_price: null,
|
||||
liquidity: null,
|
||||
volume: null,
|
||||
sparkline: [],
|
||||
recent_trades: [],
|
||||
websocket: marketData?.websocket || {},
|
||||
},
|
||||
risk: cityData?.risk,
|
||||
ai_analysis: cityData?.ai_analysis || "",
|
||||
errors: {},
|
||||
} as any
|
||||
}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer Ticker */}
|
||||
<footer className="h-8 border-t border-terminal-border bg-terminal-bg flex items-center px-4">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<span className="text-[10px] font-bold text-neon-cyan uppercase flex-shrink-0">
|
||||
TICKER:
|
||||
</span>
|
||||
<div className="flex items-center gap-6 animate-marquee whitespace-nowrap">
|
||||
{cities.slice(0, 10).map((c) => (
|
||||
<div key={c.name} className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-slate-300">
|
||||
{c.display_name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-emerald-500">
|
||||
24.5°
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-600">LTAC</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-1 text-[10px] font-black uppercase tracking-[0.16em] transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-cyan-500/30 bg-cyan-500/10 text-cyan-600 dark:text-cyan-400",
|
||||
secondary:
|
||||
"border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300",
|
||||
success:
|
||||
"border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
danger:
|
||||
"border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400",
|
||||
warning:
|
||||
"border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
outline: "border-border bg-transparent text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/40 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-cyan-500 text-white hover:bg-cyan-400 shadow-[0_0_0_1px_rgba(34,211,238,0.2)]",
|
||||
secondary:
|
||||
"bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800",
|
||||
ghost:
|
||||
"text-slate-500 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-100 dark:hover:bg-slate-900",
|
||||
outline:
|
||||
"border border-border bg-transparent hover:bg-slate-100 dark:hover:bg-slate-900",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-8 rounded-lg px-3 text-xs",
|
||||
lg: "h-11 px-5",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-2xl border border-border/80 bg-card/80 text-card-foreground shadow-xl backdrop-blur-xl",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-5", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-sm font-black uppercase tracking-[0.16em]", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-5 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-5 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-xl border border-border bg-background/70 px-4 py-2 text-sm text-foreground shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/30 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 bg-border/80",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-zinc-800/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Chart, ChartConfiguration, ChartType } from "chart.js/auto";
|
||||
|
||||
export function useChart<TType extends ChartType>(
|
||||
createConfig: () => ChartConfiguration<TType>,
|
||||
dependencies: React.DependencyList,
|
||||
) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const chartRef = useRef<Chart<TType> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const config = createConfig();
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
|
||||
chartRef.current = new Chart(canvas, config);
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, dependencies);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return canvasRef;
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
dashboardClient,
|
||||
getCityRevision,
|
||||
toCitySummary,
|
||||
} from "@/lib/dashboard-client";
|
||||
import {
|
||||
CityDetail,
|
||||
CityListItem,
|
||||
CitySummary,
|
||||
DashboardState,
|
||||
HistoryPoint,
|
||||
HistoryState,
|
||||
LoadingState,
|
||||
} from "@/lib/dashboard-types";
|
||||
|
||||
interface DashboardStoreValue extends DashboardState {
|
||||
closeFutureModal: () => void;
|
||||
closeGuide: () => void;
|
||||
closeHistory: () => void;
|
||||
closePanel: () => void;
|
||||
ensureCityDetail: (cityName: string, force?: boolean) => Promise<CityDetail>;
|
||||
futureModalDate: string | null;
|
||||
isGuideOpen: boolean;
|
||||
loadCities: () => Promise<void>;
|
||||
openFutureModal: (dateStr: string) => void;
|
||||
openGuide: () => void;
|
||||
openHistory: () => Promise<void>;
|
||||
openTodayModal: () => void;
|
||||
registerMapStopMotion: (stopMotion: () => void) => void;
|
||||
refreshAll: () => Promise<void>;
|
||||
refreshSelectedCity: () => Promise<void>;
|
||||
selectedDetail: CityDetail | null;
|
||||
selectCity: (cityName: string) => Promise<void>;
|
||||
setForecastDate: (dateStr: string | null) => void;
|
||||
}
|
||||
|
||||
const DashboardStoreContext = createContext<DashboardStoreValue | null>(null);
|
||||
|
||||
function getInitialLoadingState(): LoadingState {
|
||||
return {
|
||||
cities: false,
|
||||
cityDetail: false,
|
||||
history: false,
|
||||
refresh: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getInitialHistoryState(): HistoryState {
|
||||
return {
|
||||
dataByCity: {},
|
||||
error: null,
|
||||
isOpen: false,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function DashboardStoreProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const initialCache = dashboardClient.readCityDetailCacheBundle();
|
||||
const [cities, setCities] = useState<CityListItem[]>([]);
|
||||
const [cityDetailsByName, setCityDetailsByName] = useState<
|
||||
Record<string, CityDetail>
|
||||
>(() => initialCache.details);
|
||||
const [citySummariesByName, setCitySummariesByName] = useState<
|
||||
Record<string, CitySummary>
|
||||
>(() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(initialCache.details).map(([cityName, detail]) => [
|
||||
cityName,
|
||||
toCitySummary(detail),
|
||||
]),
|
||||
),
|
||||
);
|
||||
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
|
||||
Record<string, { cachedAt: number; revision: string }>
|
||||
>(() => initialCache.meta);
|
||||
const [selectedCity, setSelectedCity] = useState<string | null>(null);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
const [selectedForecastDate, setSelectedForecastDate] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [futureModalDate, setFutureModalDate] = useState<string | null>(null);
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>(
|
||||
getInitialLoadingState,
|
||||
);
|
||||
const [historyState, setHistoryState] = useState<HistoryState>(
|
||||
getInitialHistoryState,
|
||||
);
|
||||
const [isGuideOpen, setIsGuideOpen] = useState(false);
|
||||
|
||||
const mapStopMotionRef = useRef<() => void>(() => {});
|
||||
const citySummariesRef = useRef<Record<string, CitySummary>>(
|
||||
Object.fromEntries(
|
||||
Object.entries(initialCache.details).map(([cityName, detail]) => [
|
||||
cityName,
|
||||
toCitySummary(detail),
|
||||
]),
|
||||
),
|
||||
);
|
||||
const selectedDetail = selectedCity
|
||||
? cityDetailsByName[selectedCity] || null
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
dashboardClient.writeCityDetailCacheBundle(
|
||||
cityDetailsByName,
|
||||
cityDetailMetaByName,
|
||||
);
|
||||
}, [cityDetailMetaByName, cityDetailsByName]);
|
||||
|
||||
useEffect(() => {
|
||||
citySummariesRef.current = citySummariesByName;
|
||||
}, [citySummariesByName]);
|
||||
|
||||
const ensureCityDetail = async (cityName: string, force = false) => {
|
||||
const cached = cityDetailsByName[cityName];
|
||||
const cachedMeta = cityDetailMetaByName[cityName];
|
||||
if (!force && cached && dashboardClient.isCityDetailFresh(cachedMeta)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (!force && cached) {
|
||||
try {
|
||||
const summary = await dashboardClient.getCitySummary(cityName);
|
||||
const revision = getCityRevision(summary);
|
||||
if (revision && revision === cachedMeta?.revision) {
|
||||
setCityDetailMetaByName((current) => ({
|
||||
...current,
|
||||
[cityName]: {
|
||||
cachedAt: Date.now(),
|
||||
revision,
|
||||
},
|
||||
}));
|
||||
return cached;
|
||||
}
|
||||
} catch {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const detail = await dashboardClient.getCityDetail(cityName, { force });
|
||||
setCityDetailsByName((current) => ({
|
||||
...current,
|
||||
[cityName]: detail,
|
||||
}));
|
||||
setCitySummariesByName((current) => ({
|
||||
...current,
|
||||
[cityName]: toCitySummary(detail),
|
||||
}));
|
||||
setCityDetailMetaByName((current) => ({
|
||||
...current,
|
||||
[cityName]: {
|
||||
cachedAt: Date.now(),
|
||||
revision: getCityRevision(detail),
|
||||
},
|
||||
}));
|
||||
return detail;
|
||||
};
|
||||
|
||||
const loadCities = async () => {
|
||||
setLoadingState((current) => ({ ...current, cities: true }));
|
||||
try {
|
||||
const nextCities = await dashboardClient.getCities();
|
||||
setCities(nextCities);
|
||||
} finally {
|
||||
setLoadingState((current) => ({ ...current, cities: false }));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadCities();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cities.length) return;
|
||||
|
||||
const queue = cities
|
||||
.map((city) => city.name)
|
||||
.filter((cityName) => !citySummariesRef.current[cityName]);
|
||||
if (!queue.length) return;
|
||||
|
||||
let active = true;
|
||||
const concurrency = 4;
|
||||
let cursor = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (active && cursor < queue.length) {
|
||||
const cityName = queue[cursor];
|
||||
cursor += 1;
|
||||
if (citySummariesRef.current[cityName]) continue;
|
||||
|
||||
try {
|
||||
const summary = await dashboardClient.getCitySummary(cityName);
|
||||
if (!active) return;
|
||||
|
||||
setCitySummariesByName((current) => {
|
||||
if (current[cityName]) return current;
|
||||
const next = {
|
||||
...current,
|
||||
[cityName]: summary,
|
||||
};
|
||||
citySummariesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
void Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, queue.length) }, () =>
|
||||
worker(),
|
||||
),
|
||||
);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [cities]);
|
||||
|
||||
const selectCity = async (cityName: string) => {
|
||||
setSelectedCity(cityName);
|
||||
setIsPanelOpen(true);
|
||||
setSelectedForecastDate(null);
|
||||
setFutureModalDate(null);
|
||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||
try {
|
||||
const detail = await ensureCityDetail(cityName);
|
||||
setSelectedForecastDate(detail.local_date);
|
||||
} finally {
|
||||
setLoadingState((current) => ({ ...current, cityDetail: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSelectedCity = async () => {
|
||||
if (!selectedCity) return;
|
||||
setLoadingState((current) => ({ ...current, refresh: true }));
|
||||
try {
|
||||
const detail = await ensureCityDetail(selectedCity, true);
|
||||
setSelectedForecastDate(detail.local_date);
|
||||
} finally {
|
||||
setLoadingState((current) => ({ ...current, refresh: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
dashboardClient.clearCityDetailCache();
|
||||
setCityDetailsByName({});
|
||||
setCityDetailMetaByName({});
|
||||
if (selectedCity) {
|
||||
setLoadingState((current) => ({ ...current, refresh: true }));
|
||||
try {
|
||||
const detail = await dashboardClient.getCityDetail(selectedCity, {
|
||||
force: true,
|
||||
});
|
||||
setCityDetailsByName({ [selectedCity]: detail });
|
||||
setCitySummariesByName((current) => ({
|
||||
...current,
|
||||
[selectedCity]: toCitySummary(detail),
|
||||
}));
|
||||
setCityDetailMetaByName({
|
||||
[selectedCity]: {
|
||||
cachedAt: Date.now(),
|
||||
revision: getCityRevision(detail),
|
||||
},
|
||||
});
|
||||
setSelectedForecastDate(detail.local_date);
|
||||
} finally {
|
||||
setLoadingState((current) => ({ ...current, refresh: false }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openHistory = async () => {
|
||||
if (!selectedCity) return;
|
||||
setHistoryState((current) => ({
|
||||
...current,
|
||||
error: null,
|
||||
isOpen: true,
|
||||
loading: true,
|
||||
}));
|
||||
try {
|
||||
const history = await dashboardClient.getHistory(selectedCity);
|
||||
setHistoryState((current) => ({
|
||||
...current,
|
||||
dataByCity: {
|
||||
...current.dataByCity,
|
||||
[selectedCity]: history,
|
||||
},
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
setHistoryState((current) => ({
|
||||
...current,
|
||||
error: String(error),
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const value = useMemo<DashboardStoreValue>(
|
||||
() => ({
|
||||
cities,
|
||||
cityDetailsByName,
|
||||
citySummariesByName,
|
||||
closeFutureModal: () => setFutureModalDate(null),
|
||||
closeGuide: () => setIsGuideOpen(false),
|
||||
closeHistory: () =>
|
||||
setHistoryState((current) => ({ ...current, isOpen: false })),
|
||||
closePanel: () => {
|
||||
setIsPanelOpen(false);
|
||||
},
|
||||
ensureCityDetail,
|
||||
futureModalDate,
|
||||
historyState,
|
||||
isPanelOpen,
|
||||
isGuideOpen,
|
||||
loadCities,
|
||||
loadingState,
|
||||
openFutureModal: (dateStr: string) => {
|
||||
mapStopMotionRef.current();
|
||||
setFutureModalDate(dateStr);
|
||||
},
|
||||
openGuide: () => setIsGuideOpen(true),
|
||||
openHistory,
|
||||
openTodayModal: () => {
|
||||
if (selectedDetail?.local_date) {
|
||||
mapStopMotionRef.current();
|
||||
setFutureModalDate(selectedDetail.local_date);
|
||||
}
|
||||
},
|
||||
registerMapStopMotion: (stopMotion: () => void) => {
|
||||
mapStopMotionRef.current = stopMotion;
|
||||
},
|
||||
refreshAll,
|
||||
refreshSelectedCity,
|
||||
selectedCity,
|
||||
selectedDetail,
|
||||
selectedForecastDate,
|
||||
selectCity,
|
||||
setForecastDate: (dateStr: string | null) =>
|
||||
setSelectedForecastDate(dateStr),
|
||||
}),
|
||||
[
|
||||
cities,
|
||||
cityDetailsByName,
|
||||
citySummariesByName,
|
||||
futureModalDate,
|
||||
historyState,
|
||||
isPanelOpen,
|
||||
isGuideOpen,
|
||||
loadingState,
|
||||
selectedCity,
|
||||
selectedDetail,
|
||||
selectedForecastDate,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardStoreContext.Provider value={value}>
|
||||
{children}
|
||||
</DashboardStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDashboardStore() {
|
||||
const context = useContext(DashboardStoreContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useDashboardStore must be used within DashboardStoreProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useCityData(name?: string | null) {
|
||||
const store = useDashboardStore();
|
||||
const key = name || store.selectedCity;
|
||||
return {
|
||||
data: key ? store.cityDetailsByName[key] || null : null,
|
||||
isLoading:
|
||||
store.loadingState.cityDetail &&
|
||||
Boolean(key) &&
|
||||
store.selectedCity === key,
|
||||
};
|
||||
}
|
||||
|
||||
export function useHistoryData(name?: string | null) {
|
||||
const store = useDashboardStore();
|
||||
const key = name || store.selectedCity;
|
||||
return {
|
||||
data: key
|
||||
? store.historyState.dataByCity[key] || ([] as HistoryPoint[])
|
||||
: [],
|
||||
error: store.historyState.error,
|
||||
isLoading: store.historyState.loading,
|
||||
isOpen: store.historyState.isOpen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import L from "leaflet";
|
||||
import {
|
||||
CityDetail,
|
||||
CityListItem,
|
||||
CitySummary,
|
||||
NearbyStation,
|
||||
} from "@/lib/dashboard-types";
|
||||
import { pickAnkaraNearbyStations } from "@/lib/dashboard-utils";
|
||||
|
||||
interface UseLeafletMapArgs {
|
||||
cities: CityListItem[];
|
||||
cityDetailsByName: Record<string, CityDetail>;
|
||||
citySummariesByName: Record<string, CitySummary>;
|
||||
onClosePanel: () => void;
|
||||
onEnsureCityDetail: (
|
||||
cityName: string,
|
||||
force?: boolean,
|
||||
) => Promise<CityDetail>;
|
||||
onRegisterStopMotion: (stopMotion: () => void) => void;
|
||||
onSelectCity: (cityName: string) => void;
|
||||
selectedCity: string | null;
|
||||
selectedDetail: CityDetail | null;
|
||||
suspendMotion: boolean;
|
||||
isLoadingDetail: boolean;
|
||||
}
|
||||
|
||||
const AUTO_NEARBY_MIN_ZOOM = 8;
|
||||
const AUTO_NEARBY_MAX_DISTANCE_M = 120000;
|
||||
const MAP_MAX_ZOOM = 19;
|
||||
|
||||
function createMarkerIcon(
|
||||
city: CityListItem,
|
||||
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
|
||||
) {
|
||||
const riskClass = `risk-${city.risk_level}`;
|
||||
const label = city.display_name;
|
||||
const unit = city.temp_unit === "fahrenheit" ? "°F" : "°C";
|
||||
const shortName = label.length > 10 ? `${label.substring(0, 8)}...` : label;
|
||||
const tempText =
|
||||
snapshot?.current?.temp != null ? `${snapshot.current.temp}${unit}` : "--";
|
||||
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `
|
||||
<div class="city-marker" data-city="${city.name}">
|
||||
<div class="marker-bubble ${riskClass}">${tempText}</div>
|
||||
<div class="marker-name">${shortName}</div>
|
||||
</div>
|
||||
`,
|
||||
iconAnchor: [40, 22],
|
||||
iconSize: [80, 44],
|
||||
});
|
||||
}
|
||||
|
||||
function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
|
||||
const symbol = detail.temp_symbol || "°C";
|
||||
let windHtml = "";
|
||||
|
||||
if (station.wind_dir != null) {
|
||||
const rotation = (Number(station.wind_dir) + 180) % 360;
|
||||
const speedRaw = Number(station.wind_speed ?? station.wind_speed_kt);
|
||||
const speed = Number.isFinite(speedRaw) ? `${speedRaw.toFixed(1)}k` : "";
|
||||
windHtml = `
|
||||
<div class="nearby-wind">
|
||||
<span class="wind-arrow" style="transform: rotate(${rotation}deg)">↑</span>
|
||||
<span class="wind-val">${speed}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="nearby-marker-premium">
|
||||
<div class="nearby-pulse">
|
||||
<div class="pulse-ring"></div>
|
||||
<div class="pulse-core"></div>
|
||||
</div>
|
||||
<div class="nearby-content">
|
||||
<span class="nearby-label">${station.name || station.icao || "OBS"}</span>
|
||||
<div class="nearby-stats">
|
||||
<span class="nearby-temp-val">${station.temp ?? "--"}</span>
|
||||
<span class="nearby-temp-unit">${symbol}</span>
|
||||
</div>
|
||||
</div>
|
||||
${windHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function useLeafletMap({
|
||||
cities,
|
||||
cityDetailsByName,
|
||||
citySummariesByName,
|
||||
onClosePanel,
|
||||
onEnsureCityDetail,
|
||||
onRegisterStopMotion,
|
||||
onSelectCity,
|
||||
selectedCity,
|
||||
selectedDetail,
|
||||
suspendMotion,
|
||||
isLoadingDetail,
|
||||
}: UseLeafletMapArgs) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const markersRef = useRef<
|
||||
Record<string, { city: CityListItem; marker: L.Marker }>
|
||||
>({});
|
||||
const nearbyLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const autoNearbyCityRef = useRef<string | null>(null);
|
||||
const loadingAutoNearbyRef = useRef(false);
|
||||
const lastMovedCityRef = useRef<string | null>(null);
|
||||
const suspendMotionRef = useRef(suspendMotion);
|
||||
const hasFittedInitialBoundsRef = useRef(false);
|
||||
const onClosePanelRef = useRef(onClosePanel);
|
||||
const onRegisterStopMotionRef = useRef(onRegisterStopMotion);
|
||||
const onSelectCityRef = useRef(onSelectCity);
|
||||
const onEnsureCityDetailRef = useRef(onEnsureCityDetail);
|
||||
|
||||
useEffect(() => {
|
||||
onClosePanelRef.current = onClosePanel;
|
||||
}, [onClosePanel]);
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterStopMotionRef.current = onRegisterStopMotion;
|
||||
}, [onRegisterStopMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectCityRef.current = onSelectCity;
|
||||
}, [onSelectCity]);
|
||||
|
||||
useEffect(() => {
|
||||
onEnsureCityDetailRef.current = onEnsureCityDetail;
|
||||
}, [onEnsureCityDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
suspendMotionRef.current = suspendMotion;
|
||||
}, [suspendMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || mapRef.current) return;
|
||||
|
||||
const map = L.map(container, {
|
||||
attributionControl: true,
|
||||
bounceAtZoomLimits: false,
|
||||
center: [30, 10],
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
minZoom: 2,
|
||||
zoom: 3,
|
||||
zoomControl: false,
|
||||
});
|
||||
|
||||
L.control.zoom({ position: "bottomright" }).addTo(map);
|
||||
L.tileLayer(
|
||||
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
||||
{
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: "abcd",
|
||||
},
|
||||
).addTo(map);
|
||||
|
||||
const nearbyLayer = L.layerGroup().addTo(map);
|
||||
mapRef.current = map;
|
||||
nearbyLayerRef.current = nearbyLayer;
|
||||
|
||||
// Track which city we've already moved to for the current selection
|
||||
onRegisterStopMotionRef.current(() => {
|
||||
map.stop();
|
||||
});
|
||||
|
||||
const handleMapClick = () => {
|
||||
onClosePanelRef.current();
|
||||
};
|
||||
map.on("click", handleMapClick);
|
||||
|
||||
return () => {
|
||||
onRegisterStopMotionRef.current(() => {});
|
||||
map.off("click", handleMapClick);
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
nearbyLayerRef.current = null;
|
||||
markersRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle initial view if cities are loaded
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !cities.length || hasFittedInitialBoundsRef.current) return;
|
||||
|
||||
// Only run fitBounds once for the initial list of cities
|
||||
const bounds = cities.map((city) => [city.lat, city.lon]) as [
|
||||
number,
|
||||
number,
|
||||
][];
|
||||
if (bounds.length) {
|
||||
map.fitBounds(bounds, {
|
||||
animate: false,
|
||||
maxZoom: 4,
|
||||
padding: [60, 60],
|
||||
});
|
||||
hasFittedInitialBoundsRef.current = true;
|
||||
}
|
||||
}, [cities]);
|
||||
|
||||
const lastCityDataRef = useRef<
|
||||
Record<string, { temp?: number | null; risk?: string }>
|
||||
>({});
|
||||
|
||||
// Handle marker synchronization
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !cities.length) return;
|
||||
|
||||
const currentMarkers = markersRef.current;
|
||||
const nextMarkers: typeof currentMarkers = {};
|
||||
const nextLastData: typeof lastCityDataRef.current = {};
|
||||
|
||||
cities.forEach((city) => {
|
||||
const detail = cityDetailsByName[city.name];
|
||||
const summary = citySummariesByName[city.name];
|
||||
const snapshot = detail || summary;
|
||||
const existing = currentMarkers[city.name];
|
||||
|
||||
const currentTemp = snapshot?.current?.temp;
|
||||
const currentRisk = city.risk_level;
|
||||
const lastData = lastCityDataRef.current[city.name];
|
||||
const dataChanged =
|
||||
!lastData ||
|
||||
lastData.temp !== currentTemp ||
|
||||
lastData.risk !== currentRisk;
|
||||
|
||||
if (existing) {
|
||||
if (dataChanged) {
|
||||
existing.marker.setIcon(createMarkerIcon(city, snapshot));
|
||||
}
|
||||
nextMarkers[city.name] = { city, marker: existing.marker };
|
||||
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new marker
|
||||
const marker = L.marker([city.lat, city.lon], {
|
||||
icon: createMarkerIcon(city, snapshot),
|
||||
}).addTo(map);
|
||||
|
||||
marker.on("click", () => {
|
||||
map.stop();
|
||||
// Reset lastMovedCity so we can re-fly if needed
|
||||
lastMovedCityRef.current = null;
|
||||
onSelectCityRef.current(city.name);
|
||||
});
|
||||
|
||||
nextMarkers[city.name] = { city, marker };
|
||||
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
|
||||
});
|
||||
|
||||
// Cleanup removed markers
|
||||
Object.entries(currentMarkers).forEach(([name, entry]) => {
|
||||
if (!nextMarkers[name]) {
|
||||
map.removeLayer(entry.marker);
|
||||
}
|
||||
});
|
||||
|
||||
markersRef.current = nextMarkers;
|
||||
lastCityDataRef.current = nextLastData;
|
||||
}, [cities, cityDetailsByName, citySummariesByName]);
|
||||
|
||||
useEffect(() => {
|
||||
Object.entries(markersRef.current).forEach(([name, entry]) => {
|
||||
const element = entry.marker.getElement();
|
||||
if (!element) return;
|
||||
const markerRoot = element.querySelector(".city-marker");
|
||||
markerRoot?.classList.toggle("selected", name === selectedCity);
|
||||
});
|
||||
}, [selectedCity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || !nearbyLayerRef.current) return;
|
||||
const map = mapRef.current;
|
||||
const layer = nearbyLayerRef.current;
|
||||
|
||||
function renderNearbyStations(detail: CityDetail, preserveView = false) {
|
||||
layer.clearLayers();
|
||||
|
||||
const allNearby = Array.isArray(detail.mgm_nearby)
|
||||
? detail.mgm_nearby
|
||||
: [];
|
||||
const nearbyStations =
|
||||
String(detail.name || "").toLowerCase() === "ankara"
|
||||
? pickAnkaraNearbyStations(allNearby)
|
||||
: allNearby;
|
||||
|
||||
if (!nearbyStations.length) {
|
||||
if (!preserveView && detail.lat != null && detail.lon != null) {
|
||||
map.flyTo([detail.lat, detail.lon], 10, {
|
||||
animate: true,
|
||||
duration: 1.5,
|
||||
easeLinearity: 0.25,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const latLngs: Array<[number, number]> = [];
|
||||
if (detail.lat != null && detail.lon != null) {
|
||||
latLngs.push([detail.lat, detail.lon]);
|
||||
}
|
||||
|
||||
nearbyStations.forEach((station) => {
|
||||
const sLat = Number(station.lat);
|
||||
const sLon = Number(station.lon);
|
||||
// Ignore invalid (0,0) or null coordinates which cause global zoom-out
|
||||
if (!Number.isFinite(sLat) || !Number.isFinite(sLon)) return;
|
||||
if (Math.abs(sLat) < 0.1 && Math.abs(sLon) < 0.1) return;
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: "",
|
||||
html: buildNearbyIconHtml(detail, station),
|
||||
iconAnchor: [16, 19],
|
||||
iconSize: [240, 38],
|
||||
});
|
||||
L.marker([sLat, sLon], { icon }).addTo(layer);
|
||||
latLngs.push([sLat, sLon]);
|
||||
});
|
||||
|
||||
if (preserveView) return;
|
||||
|
||||
// Note: Movement for selected cities is now handled by the centralized effect.
|
||||
// This section is primarily for auto-discovery movement if needed.
|
||||
}
|
||||
|
||||
async function maybeAutoShowNearbyStations() {
|
||||
if (suspendMotion) {
|
||||
map.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedDetail) {
|
||||
// Just render stations, no camera move from here
|
||||
renderNearbyStations(selectedDetail, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no city selected, reset the move tracker
|
||||
lastMovedCityRef.current = null;
|
||||
|
||||
if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) {
|
||||
autoNearbyCityRef.current = null;
|
||||
layer.clearLayers();
|
||||
return;
|
||||
}
|
||||
|
||||
const center = map.getCenter();
|
||||
let best: { cityName: string; distance: number } | null = null;
|
||||
for (const [cityName, entry] of Object.entries(markersRef.current)) {
|
||||
const distance = map.distance(
|
||||
center,
|
||||
L.latLng(entry.city.lat, entry.city.lon),
|
||||
);
|
||||
if (distance > AUTO_NEARBY_MAX_DISTANCE_M) continue;
|
||||
if (!best || distance < best.distance) {
|
||||
best = { cityName, distance };
|
||||
}
|
||||
}
|
||||
|
||||
const targetCity = best?.cityName || null;
|
||||
if (!targetCity) {
|
||||
autoNearbyCityRef.current = null;
|
||||
layer.clearLayers();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
autoNearbyCityRef.current === targetCity &&
|
||||
layer.getLayers().length > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoNearbyCityRef.current = targetCity;
|
||||
const cachedDetail = cityDetailsByName[targetCity];
|
||||
if (cachedDetail) {
|
||||
renderNearbyStations(cachedDetail, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadingAutoNearbyRef.current) return;
|
||||
loadingAutoNearbyRef.current = true;
|
||||
try {
|
||||
const detail = await onEnsureCityDetailRef.current(targetCity, false);
|
||||
renderNearbyStations(detail, true);
|
||||
} catch {
|
||||
} finally {
|
||||
loadingAutoNearbyRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
const syncVisibility = () => {
|
||||
if (suspendMotion) {
|
||||
map.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (map.getZoom() < 7) {
|
||||
if (map.hasLayer(layer)) {
|
||||
map.removeLayer(layer);
|
||||
}
|
||||
} else if (!map.hasLayer(layer)) {
|
||||
map.addLayer(layer);
|
||||
}
|
||||
void maybeAutoShowNearbyStations();
|
||||
};
|
||||
|
||||
syncVisibility();
|
||||
map.on("zoomend", syncVisibility);
|
||||
map.on("moveend", maybeAutoShowNearbyStations);
|
||||
|
||||
return () => {
|
||||
map.off("zoomend", syncVisibility);
|
||||
map.off("moveend", maybeAutoShowNearbyStations);
|
||||
};
|
||||
}, [cityDetailsByName, selectedCity, selectedDetail, suspendMotion]);
|
||||
|
||||
// Centralized City Selection Zoom Effect
|
||||
// Higher level than selection: we only flyTo once the data is loaded (selectedDetail)
|
||||
// This satisfies "loading之后再出现动画吧"
|
||||
useEffect(() => {
|
||||
if (!selectedCity) {
|
||||
lastMovedCityRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const map = mapRef.current;
|
||||
if (!map || suspendMotion || !selectedDetail || isLoadingDetail) return;
|
||||
|
||||
// Check if the detail matches the selection (case-insensitive)
|
||||
if (selectedDetail.name?.toLowerCase() !== selectedCity.toLowerCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastMovedCityRef.current === selectedCity) return;
|
||||
|
||||
const entry = markersRef.current[selectedCity];
|
||||
if (!entry) return;
|
||||
|
||||
// Lock the move
|
||||
lastMovedCityRef.current = selectedCity;
|
||||
|
||||
// We use a micro-delay (50ms) to allow the browser to settle
|
||||
// after the loading overlay disappears and the detail panel renders.
|
||||
const timer = setTimeout(() => {
|
||||
const currentMap = mapRef.current;
|
||||
if (
|
||||
!currentMap ||
|
||||
lastMovedCityRef.current !== selectedCity ||
|
||||
suspendMotion
|
||||
)
|
||||
return;
|
||||
|
||||
currentMap.stop();
|
||||
currentMap.flyTo([entry.city.lat, entry.city.lon], 11, {
|
||||
animate: true,
|
||||
duration: 1.1,
|
||||
easeLinearity: 0.22,
|
||||
});
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [selectedCity, selectedDetail, suspendMotion, isLoadingDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!suspendMotion) return;
|
||||
mapRef.current?.stop();
|
||||
}, [suspendMotion]);
|
||||
|
||||
return { containerRef };
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CityDetail,
|
||||
CityListItem,
|
||||
CitySummary,
|
||||
HistoryPoint,
|
||||
} from "@/lib/dashboard-types";
|
||||
|
||||
const CACHE_KEY = "polyWeather_v1";
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
|
||||
const pendingHistoryRequests = new Map<string, Promise<HistoryPoint[]>>();
|
||||
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
|
||||
|
||||
type CityCacheMeta = {
|
||||
cachedAt: number;
|
||||
revision: string;
|
||||
};
|
||||
|
||||
type CityCacheBundle = {
|
||||
details: Record<string, CityDetail>;
|
||||
meta: Record<string, CityCacheMeta>;
|
||||
};
|
||||
|
||||
function normalizeCityName(cityName: string) {
|
||||
return encodeURIComponent(String(cityName).replace(/\s/g, "-"));
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function isClient() {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
function normalizeRevisionPart(value: unknown) {
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
export function getCityRevision(source?: CityDetail | CitySummary | null) {
|
||||
if (!source) return "";
|
||||
return [
|
||||
normalizeRevisionPart(source.updated_at),
|
||||
normalizeRevisionPart(source.current?.obs_time),
|
||||
normalizeRevisionPart(source.current?.temp),
|
||||
normalizeRevisionPart(source.deb?.prediction),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function toCitySummary(detail: CityDetail): CitySummary {
|
||||
return {
|
||||
name: detail.name,
|
||||
display_name: detail.display_name,
|
||||
icao: detail.risk?.icao,
|
||||
local_time: detail.local_time,
|
||||
temp_symbol: detail.temp_symbol,
|
||||
current: {
|
||||
obs_time: detail.current?.obs_time,
|
||||
temp: detail.current?.temp,
|
||||
},
|
||||
deb: {
|
||||
prediction: detail.deb?.prediction,
|
||||
},
|
||||
risk: {
|
||||
level: detail.risk?.level,
|
||||
warning: detail.risk?.warning,
|
||||
},
|
||||
updated_at: detail.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function isFresh(meta?: CityCacheMeta | null) {
|
||||
return Boolean(meta && Date.now() - meta.cachedAt < CACHE_TTL_MS);
|
||||
}
|
||||
|
||||
function readLegacyCache(raw: string): CityCacheBundle {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
timestamp?: number;
|
||||
data?: Record<string, CityDetail>;
|
||||
};
|
||||
const details = parsed.data || {};
|
||||
const cachedAt = parsed.timestamp || 0;
|
||||
const meta = Object.fromEntries(
|
||||
Object.entries(details).map(([cityName, detail]) => [
|
||||
cityName,
|
||||
{
|
||||
cachedAt,
|
||||
revision: getCityRevision(detail),
|
||||
},
|
||||
]),
|
||||
);
|
||||
return { details, meta };
|
||||
}
|
||||
|
||||
export const dashboardClient = {
|
||||
clearCityDetailCache() {
|
||||
if (!isClient()) return;
|
||||
window.sessionStorage.removeItem(CACHE_KEY);
|
||||
},
|
||||
|
||||
async getCities() {
|
||||
const data = await fetchJson<{ cities?: CityListItem[] }>("/api/cities");
|
||||
return data.cities || [];
|
||||
},
|
||||
|
||||
async getCitySummary(cityName: string, options?: { force?: boolean }) {
|
||||
const force = options?.force ?? false;
|
||||
const requestKey = `${cityName}::${force ? "force" : "cached"}`;
|
||||
const existing = pendingCitySummaryRequests.get(requestKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const request = fetchJson<CitySummary>(
|
||||
`/api/city/${normalizeCityName(cityName)}/summary?force_refresh=${force}`,
|
||||
).finally(() => {
|
||||
pendingCitySummaryRequests.delete(requestKey);
|
||||
});
|
||||
|
||||
pendingCitySummaryRequests.set(requestKey, request);
|
||||
return request;
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const request = fetchJson<CityDetail>(
|
||||
`/api/city/${normalizeCityName(cityName)}?force_refresh=${force}`,
|
||||
).finally(() => {
|
||||
pendingCityDetailRequests.delete(requestKey);
|
||||
});
|
||||
|
||||
pendingCityDetailRequests.set(requestKey, request);
|
||||
return request;
|
||||
},
|
||||
|
||||
async getHistory(cityName: string) {
|
||||
const requestKey = normalizeCityName(cityName);
|
||||
const existing = pendingHistoryRequests.get(requestKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const request = fetchJson<{ history?: HistoryPoint[] }>(
|
||||
`/api/history/${requestKey}`,
|
||||
)
|
||||
.then((data) => data.history || [])
|
||||
.finally(() => {
|
||||
pendingHistoryRequests.delete(requestKey);
|
||||
});
|
||||
|
||||
pendingHistoryRequests.set(requestKey, request);
|
||||
return request;
|
||||
},
|
||||
|
||||
isCityDetailFresh(meta?: CityCacheMeta | null) {
|
||||
return isFresh(meta);
|
||||
},
|
||||
|
||||
readCityDetailCacheBundle() {
|
||||
if (!isClient()) {
|
||||
return {
|
||||
details: {},
|
||||
meta: {},
|
||||
} satisfies CityCacheBundle;
|
||||
}
|
||||
|
||||
try {
|
||||
const cached = window.sessionStorage.getItem(CACHE_KEY);
|
||||
if (!cached) {
|
||||
return {
|
||||
details: {},
|
||||
meta: {},
|
||||
} satisfies CityCacheBundle;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(cached) as
|
||||
| {
|
||||
entries?: Record<
|
||||
string,
|
||||
{ cachedAt?: number; detail?: CityDetail; revision?: string }
|
||||
>;
|
||||
}
|
||||
| {
|
||||
timestamp?: number;
|
||||
data?: Record<string, CityDetail>;
|
||||
};
|
||||
|
||||
if ("entries" in parsed && parsed.entries) {
|
||||
const details: Record<string, CityDetail> = {};
|
||||
const meta: Record<string, CityCacheMeta> = {};
|
||||
Object.entries(parsed.entries).forEach(([cityName, entry]) => {
|
||||
if (!entry?.detail) return;
|
||||
details[cityName] = entry.detail;
|
||||
meta[cityName] = {
|
||||
cachedAt: entry.cachedAt || 0,
|
||||
revision: entry.revision || getCityRevision(entry.detail),
|
||||
};
|
||||
});
|
||||
return { details, meta };
|
||||
}
|
||||
|
||||
return readLegacyCache(cached);
|
||||
} catch {
|
||||
return {
|
||||
details: {},
|
||||
meta: {},
|
||||
} satisfies CityCacheBundle;
|
||||
}
|
||||
},
|
||||
|
||||
readCityDetailCache() {
|
||||
return this.readCityDetailCacheBundle().details;
|
||||
},
|
||||
|
||||
writeCityDetailCacheBundle(
|
||||
details: Record<string, CityDetail>,
|
||||
meta: Record<string, CityCacheMeta>,
|
||||
) {
|
||||
if (!isClient()) return;
|
||||
const entries = Object.fromEntries(
|
||||
Object.entries(details).map(([cityName, detail]) => [
|
||||
cityName,
|
||||
{
|
||||
cachedAt: meta[cityName]?.cachedAt || Date.now(),
|
||||
detail,
|
||||
revision: meta[cityName]?.revision || getCityRevision(detail),
|
||||
},
|
||||
]),
|
||||
);
|
||||
window.sessionStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({ entries }),
|
||||
);
|
||||
},
|
||||
|
||||
writeCityDetailCache(data: Record<string, CityDetail>) {
|
||||
const now = Date.now();
|
||||
const meta = Object.fromEntries(
|
||||
Object.entries(data).map(([cityName, detail]) => [
|
||||
cityName,
|
||||
{ cachedAt: now, revision: getCityRevision(detail) },
|
||||
]),
|
||||
);
|
||||
this.writeCityDetailCacheBundle(data, meta);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
export interface CityScenery {
|
||||
creditLabel: string;
|
||||
creditUrl: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SCENERY: CityScenery = {
|
||||
creditLabel: "Pexels / City scenery",
|
||||
creditUrl: "https://www.pexels.com/",
|
||||
imageUrl: "/scenery/city-default.jpg",
|
||||
};
|
||||
|
||||
export const CITY_SCENERY: Record<string, CityScenery> = {
|
||||
ankara: {
|
||||
creditLabel: "Pexels / Ankara skyline",
|
||||
creditUrl: "https://www.pexels.com/",
|
||||
imageUrl: "/scenery/ankara.jpg",
|
||||
},
|
||||
chicago: {
|
||||
creditLabel: "Pexels / Chicago skyline",
|
||||
creditUrl: "https://www.pexels.com/",
|
||||
imageUrl: "/scenery/chicago.jpg",
|
||||
},
|
||||
london: {
|
||||
creditLabel: "Pexels / London skyline",
|
||||
creditUrl: "https://www.pexels.com/",
|
||||
imageUrl: "/scenery/london.jpg",
|
||||
},
|
||||
lucknow: {
|
||||
creditLabel: "Pexels / Lucknow heritage",
|
||||
creditUrl: "https://www.pexels.com/",
|
||||
imageUrl: "/scenery/lucknow.jpg",
|
||||
},
|
||||
munich: {
|
||||
creditLabel: "Pexels / Munich streetscape",
|
||||
creditUrl: "https://www.pexels.com/",
|
||||
imageUrl: "/scenery/munich.jpg",
|
||||
},
|
||||
"new york": {
|
||||
creditLabel: "Pexels / New York skyline",
|
||||
creditUrl: "https://www.pexels.com/",
|
||||
imageUrl: "/scenery/new-york.jpg",
|
||||
},
|
||||
"new york city": {
|
||||
creditLabel: "Pexels / New York skyline",
|
||||
creditUrl: "https://www.pexels.com/",
|
||||
imageUrl: "/scenery/new-york.jpg",
|
||||
},
|
||||
paris: {
|
||||
creditLabel: "Pexels / Paris streetscape",
|
||||
creditUrl: "https://www.pexels.com/",
|
||||
imageUrl: "/scenery/paris.jpg",
|
||||
},
|
||||
seoul: {
|
||||
creditLabel: "Pexels / Seoul cityscape",
|
||||
creditUrl: "https://www.pexels.com/",
|
||||
imageUrl: "/scenery/seoul.jpg",
|
||||
},
|
||||
"sao paulo": {
|
||||
creditLabel: "Pexels / Sao Paulo skyline",
|
||||
creditUrl: "https://www.pexels.com/",
|
||||
imageUrl: "/scenery/sao-paulo.jpg",
|
||||
},
|
||||
"são paulo": {
|
||||
creditLabel: "Pexels / Sao Paulo skyline",
|
||||
creditUrl: "https://www.pexels.com/",
|
||||
imageUrl: "/scenery/sao-paulo.jpg",
|
||||
},
|
||||
"s茫o paulo": {
|
||||
creditLabel: "Pexels / Sao Paulo skyline",
|
||||
creditUrl: "https://www.pexels.com/",
|
||||
imageUrl: "/scenery/sao-paulo.jpg",
|
||||
},
|
||||
toronto: {
|
||||
creditLabel: "Pexels / Toronto skyline",
|
||||
creditUrl: "https://www.pexels.com/",
|
||||
imageUrl: "/scenery/toronto.jpg",
|
||||
},
|
||||
};
|
||||
|
||||
export function getCityScenery(cityName?: string | null) {
|
||||
if (!cityName) return null;
|
||||
return CITY_SCENERY[String(cityName).toLowerCase()] || DEFAULT_SCENERY;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
export type RiskLevel = "low" | "medium" | "high" | string;
|
||||
|
||||
export interface CityListItem {
|
||||
name: string;
|
||||
display_name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
risk_level: RiskLevel;
|
||||
risk_emoji?: string;
|
||||
airport: string;
|
||||
icao: string;
|
||||
temp_unit: "celsius" | "fahrenheit";
|
||||
is_major?: boolean;
|
||||
}
|
||||
|
||||
export interface ProbabilityBucket {
|
||||
value?: number | null;
|
||||
label?: string | null;
|
||||
bucket?: string | null;
|
||||
range?: string | null;
|
||||
unit?: string | null;
|
||||
probability?: number | null;
|
||||
}
|
||||
|
||||
export interface ModelForecastEntry {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface DashboardRisk {
|
||||
level: RiskLevel;
|
||||
emoji?: string;
|
||||
airport?: string;
|
||||
icao?: string;
|
||||
distance_km?: number | null;
|
||||
warning?: string | null;
|
||||
}
|
||||
|
||||
export interface CloudLayer {
|
||||
cover: string;
|
||||
base: number | null;
|
||||
}
|
||||
|
||||
export interface CurrentConditions {
|
||||
temp: number | null;
|
||||
max_so_far: number | null;
|
||||
max_temp_time: string | null;
|
||||
wu_settlement: number | null;
|
||||
obs_time: string | null;
|
||||
obs_age_min: number | null;
|
||||
wind_speed_kt: number | null;
|
||||
wind_dir: number | null;
|
||||
humidity: number | null;
|
||||
cloud_desc: string | null;
|
||||
clouds_raw: CloudLayer[];
|
||||
visibility_mi: number | null;
|
||||
wx_desc: string | null;
|
||||
raw_metar?: string | null;
|
||||
report_time?: string | null;
|
||||
receipt_time?: string | null;
|
||||
obs_time_epoch?: number | null;
|
||||
dewpoint?: number | null;
|
||||
}
|
||||
|
||||
export interface NearbyStation {
|
||||
name?: string;
|
||||
icao?: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
temp: number | null;
|
||||
wind_dir?: number | null;
|
||||
wind_speed?: number | null;
|
||||
wind_speed_kt?: number | null;
|
||||
}
|
||||
|
||||
export interface HourlyTrendPoint {
|
||||
time: string;
|
||||
temp: number;
|
||||
}
|
||||
|
||||
export interface TrendInfo {
|
||||
direction?: string;
|
||||
recent?: HourlyTrendPoint[];
|
||||
is_cooling?: boolean;
|
||||
is_dead_market?: boolean;
|
||||
}
|
||||
|
||||
export interface PeakInfo {
|
||||
hours?: string[];
|
||||
first_h?: number;
|
||||
last_h?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface MgmData {
|
||||
temp?: number | null;
|
||||
time?: string | null;
|
||||
today_high?: number | null;
|
||||
today_low?: number | null;
|
||||
hourly?: Array<{
|
||||
time?: string | null;
|
||||
temp?: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ForecastDay {
|
||||
date: string;
|
||||
max_temp: number | null;
|
||||
min_temp?: number | null;
|
||||
}
|
||||
|
||||
export interface ForecastData {
|
||||
today_high?: number | null;
|
||||
daily?: ForecastDay[];
|
||||
sunrise?: string | null;
|
||||
sunset?: string | null;
|
||||
sunshine_hours?: number | null;
|
||||
}
|
||||
|
||||
export interface DebForecast {
|
||||
prediction: number | null;
|
||||
weights_info?: string | null;
|
||||
}
|
||||
|
||||
export interface CitySummary {
|
||||
name: string;
|
||||
display_name?: string | null;
|
||||
icao?: string | null;
|
||||
local_time?: string | null;
|
||||
temp_symbol?: string | null;
|
||||
current?: {
|
||||
temp?: number | null;
|
||||
obs_time?: string | null;
|
||||
};
|
||||
deb?: {
|
||||
prediction?: number | null;
|
||||
};
|
||||
risk?: {
|
||||
level?: RiskLevel;
|
||||
warning?: string | null;
|
||||
};
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface HourlySeries {
|
||||
times?: string[];
|
||||
temps?: Array<number | null>;
|
||||
dew_point?: Array<number | null>;
|
||||
pressure_msl?: Array<number | null>;
|
||||
wind_speed_10m?: Array<number | null>;
|
||||
wind_direction_10m?: Array<number | null>;
|
||||
precipitation_probability?: Array<number | null>;
|
||||
cloud_cover?: Array<number | null>;
|
||||
radiation?: Array<number | null>;
|
||||
}
|
||||
|
||||
export interface WeatherGovPeriod {
|
||||
name?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
short_forecast?: string | null;
|
||||
detailed_forecast?: string | null;
|
||||
temperature?: number | null;
|
||||
temperature_unit?: string | null;
|
||||
}
|
||||
|
||||
export interface SourceForecasts {
|
||||
weather_gov?: {
|
||||
forecast_periods?: WeatherGovPeriod[];
|
||||
};
|
||||
meteoblue?: {
|
||||
daily_highs?: Array<number | null>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DailyModelForecast {
|
||||
models?: Record<string, number | null>;
|
||||
deb?: {
|
||||
prediction?: number | null;
|
||||
};
|
||||
probabilities?: ProbabilityBucket[];
|
||||
}
|
||||
|
||||
export interface AiAnalysisStructured {
|
||||
summary?: string | null;
|
||||
text?: string | null;
|
||||
message?: string | null;
|
||||
highlights?: string[];
|
||||
points?: string[];
|
||||
}
|
||||
|
||||
export interface CityDetail {
|
||||
name: string;
|
||||
display_name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
temp_symbol: string;
|
||||
local_time: string;
|
||||
local_date: string;
|
||||
risk: DashboardRisk;
|
||||
current: CurrentConditions;
|
||||
mgm?: MgmData;
|
||||
mgm_nearby?: NearbyStation[];
|
||||
forecast?: ForecastData;
|
||||
multi_model?: Record<string, number | null>;
|
||||
deb?: DebForecast;
|
||||
probabilities?: {
|
||||
mu?: number | null;
|
||||
distribution?: ProbabilityBucket[];
|
||||
};
|
||||
hourly?: {
|
||||
times?: string[];
|
||||
temps?: Array<number | null>;
|
||||
};
|
||||
hourly_next_48h?: HourlySeries;
|
||||
metar_recent_obs?: Array<{
|
||||
time?: string;
|
||||
temp?: number | null;
|
||||
}>;
|
||||
metar_today_obs?: Array<{
|
||||
time?: string;
|
||||
temp?: number | null;
|
||||
}>;
|
||||
trend?: TrendInfo;
|
||||
peak?: PeakInfo;
|
||||
ai_analysis?: string | AiAnalysisStructured | null;
|
||||
updated_at?: string;
|
||||
multi_model_daily?: Record<string, DailyModelForecast>;
|
||||
source_forecasts?: SourceForecasts;
|
||||
}
|
||||
|
||||
export interface HistoryPoint {
|
||||
date: string;
|
||||
actual: number | null;
|
||||
deb: number | null;
|
||||
mgm?: number | null;
|
||||
}
|
||||
|
||||
export interface LoadingState {
|
||||
cities: boolean;
|
||||
cityDetail: boolean;
|
||||
refresh: boolean;
|
||||
history: boolean;
|
||||
}
|
||||
|
||||
export interface HistoryState {
|
||||
isOpen: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
dataByCity: Record<string, HistoryPoint[]>;
|
||||
}
|
||||
|
||||
export interface DashboardState {
|
||||
cities: CityListItem[];
|
||||
cityDetailsByName: Record<string, CityDetail>;
|
||||
citySummariesByName: Record<string, CitySummary>;
|
||||
selectedCity: string | null;
|
||||
isPanelOpen: boolean;
|
||||
selectedForecastDate: string | null;
|
||||
loadingState: LoadingState;
|
||||
historyState: HistoryState;
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
import { AiAnalysisStructured, CityDetail, HistoryPoint, NearbyStation } from "@/lib/dashboard-types";
|
||||
|
||||
const METAR_WX_MAP: Record<string, { label: string; icon: string }> = {
|
||||
RA: { label: "降雨", icon: "🌧️" },
|
||||
"-RA": { label: "小雨", icon: "🌦️" },
|
||||
"+RA": { label: "强降雨", icon: "⛈️" },
|
||||
SN: { label: "降雪", icon: "❄️" },
|
||||
"-SN": { label: "小雪", icon: "🌨️" },
|
||||
"+SN": { label: "大雪", icon: "🌨️" },
|
||||
DZ: { label: "毛毛雨", icon: "🌦️" },
|
||||
FG: { label: "雾", icon: "🌫️" },
|
||||
BR: { label: "薄雾", icon: "🌫️" },
|
||||
HZ: { label: "霾", icon: "🌫️" },
|
||||
TS: { label: "雷暴", icon: "⛈️" },
|
||||
VCTS: { label: "附近雷暴", icon: "⛈️" },
|
||||
SQ: { label: "飑线", icon: "💨" },
|
||||
GS: { label: "冰雹", icon: "🌨️" },
|
||||
};
|
||||
|
||||
export function translateMetar(code?: string | null) {
|
||||
if (!code) return null;
|
||||
for (const [key, value] of Object.entries(METAR_WX_MAP)) {
|
||||
if (String(code).includes(key)) return value;
|
||||
}
|
||||
return { label: code, icon: "🌤️" };
|
||||
}
|
||||
|
||||
export function getRiskBadgeLabel(level?: string | null) {
|
||||
return (
|
||||
{
|
||||
high: "🔴 高风险",
|
||||
medium: "🟠 中风险",
|
||||
low: "🟢 低风险",
|
||||
}[String(level || "low")] || "未知风险"
|
||||
);
|
||||
}
|
||||
|
||||
export function getWeatherSummary(detail: CityDetail) {
|
||||
const current = detail.current || {};
|
||||
let weatherText = current.cloud_desc || "未知";
|
||||
let weatherIcon =
|
||||
{
|
||||
多云: "☁️",
|
||||
阴天: "☁️",
|
||||
少云: "🌤️",
|
||||
散云: "⛅",
|
||||
晴: "☀️",
|
||||
晴朗: "☀️",
|
||||
}[String(current.cloud_desc || "")] || "🌤️";
|
||||
|
||||
if (current.wx_desc) {
|
||||
const translated = translateMetar(current.wx_desc);
|
||||
if (translated) {
|
||||
weatherText = translated.label;
|
||||
weatherIcon = translated.icon;
|
||||
}
|
||||
}
|
||||
|
||||
return { weatherIcon, weatherText };
|
||||
}
|
||||
|
||||
export function getHeroMetaItems(detail: CityDetail) {
|
||||
const current = detail.current || {};
|
||||
const parts: string[] = [];
|
||||
|
||||
if (current.obs_time) {
|
||||
const ageText =
|
||||
current.obs_age_min != null && current.obs_age_min >= 30
|
||||
? `(${current.obs_age_min} 分钟前)`
|
||||
: "";
|
||||
parts.push(`✈️ METAR ${current.obs_time}${ageText}`);
|
||||
}
|
||||
|
||||
if (current.wx_desc) {
|
||||
const translated = translateMetar(current.wx_desc);
|
||||
if (translated) {
|
||||
parts.push(`${translated.icon} ${translated.label}`);
|
||||
}
|
||||
} else if (current.cloud_desc) {
|
||||
parts.push(`☁️ ${current.cloud_desc}`);
|
||||
}
|
||||
|
||||
if (current.wind_speed_kt != null) {
|
||||
parts.push(`💨 ${current.wind_speed_kt}kt`);
|
||||
}
|
||||
|
||||
if (current.visibility_mi != null) {
|
||||
parts.push(`👁️ ${current.visibility_mi}mi`);
|
||||
}
|
||||
|
||||
if (detail.mgm?.temp != null) {
|
||||
const timeMatch = detail.mgm.time?.match(/T?(\d{2}:\d{2})/);
|
||||
const timeText = timeMatch ? ` @${timeMatch[1]}` : "";
|
||||
parts.push(`📡 MGM 实测: ${detail.mgm.temp}${detail.temp_symbol}${timeText}`);
|
||||
}
|
||||
|
||||
const trend = detail.trend || {};
|
||||
if (trend.is_dead_market) {
|
||||
parts.push("☠️ 死盘");
|
||||
} else if (trend.direction && trend.direction !== "unknown") {
|
||||
const labels: Record<string, string> = {
|
||||
rising: "📈 升温中",
|
||||
falling: "📉 降温中",
|
||||
stagnant: "⏸️ 持平",
|
||||
mixed: "📊 波动中",
|
||||
};
|
||||
parts.push(labels[trend.direction] || trend.direction);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function getTemperatureChartData(detail: CityDetail) {
|
||||
const hourly = detail.hourly || {};
|
||||
const times = hourly.times || [];
|
||||
const temps = hourly.temps || [];
|
||||
|
||||
if (!times.length) return null;
|
||||
|
||||
const currentHour = detail.local_time
|
||||
? `${detail.local_time.split(":")[0]}:00`
|
||||
: null;
|
||||
const currentIndex = currentHour ? times.indexOf(currentHour) : -1;
|
||||
const omMax = detail.forecast?.today_high;
|
||||
const debMax = detail.deb?.prediction;
|
||||
const offset =
|
||||
debMax != null && omMax != null ? Number(debMax) - Number(omMax) : 0;
|
||||
const debTemps = temps.map((temp) =>
|
||||
temp != null ? Number((temp + offset).toFixed(1)) : null,
|
||||
);
|
||||
const debPast = debTemps.map((temp, index) =>
|
||||
currentIndex >= 0 && index <= currentIndex ? temp : null,
|
||||
);
|
||||
const debFuture = debTemps.map((temp, index) =>
|
||||
currentIndex < 0 || index >= currentIndex ? temp : null,
|
||||
);
|
||||
|
||||
const metarPoints = new Array(times.length).fill(null);
|
||||
const metarSource = detail.metar_today_obs?.length
|
||||
? detail.metar_today_obs
|
||||
: detail.trend?.recent || [];
|
||||
|
||||
metarSource.forEach((item) => {
|
||||
const parts = String(item.time || "").split(":");
|
||||
let hour = Number.parseInt(parts[0], 10);
|
||||
const minute = Number.parseInt(parts[1] || "0", 10);
|
||||
if (Number.isNaN(hour)) return;
|
||||
if (minute >= 30) hour = (hour + 1) % 24;
|
||||
const key = `${String(hour).padStart(2, "0")}:00`;
|
||||
const index = times.indexOf(key);
|
||||
if (index >= 0 && metarPoints[index] === null) {
|
||||
metarPoints[index] = item.temp ?? null;
|
||||
}
|
||||
});
|
||||
|
||||
const mgmPoints = new Array(times.length).fill(null);
|
||||
if (detail.mgm?.temp != null && detail.mgm?.time) {
|
||||
const match = detail.mgm.time.match(/T?(\d{2}):(\d{2})/);
|
||||
if (match) {
|
||||
let hour = Number.parseInt(match[1], 10);
|
||||
const minute = Number.parseInt(match[2], 10);
|
||||
if (minute >= 30) hour = (hour + 1) % 24;
|
||||
const key = `${String(hour).padStart(2, "0")}:00`;
|
||||
const index = times.indexOf(key);
|
||||
if (index >= 0) {
|
||||
mgmPoints[index] = detail.mgm.temp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mgmHourlyPoints = new Array(times.length).fill(null);
|
||||
let hasMgmHourly = false;
|
||||
detail.mgm?.hourly?.forEach((item) => {
|
||||
const match = String(item.time || "").match(/T?(\d{2}):(\d{2})/);
|
||||
if (!match) return;
|
||||
const key = `${match[1]}:00`;
|
||||
const index = times.indexOf(key);
|
||||
if (index >= 0) {
|
||||
mgmHourlyPoints[index] = item.temp ?? null;
|
||||
hasMgmHourly = true;
|
||||
}
|
||||
});
|
||||
|
||||
const allValues = [
|
||||
...debTemps.filter((value) => value != null),
|
||||
...metarPoints.filter((value) => value != null),
|
||||
...mgmPoints.filter((value) => value != null),
|
||||
...mgmHourlyPoints.filter((value) => value != null),
|
||||
] as number[];
|
||||
|
||||
if (!allValues.length) return null;
|
||||
|
||||
const min = Math.floor(Math.min(...allValues)) - 1;
|
||||
const max = Math.ceil(Math.max(...allValues)) + 1;
|
||||
|
||||
const legendParts: string[] = [];
|
||||
if (detail.mgm?.temp != null) {
|
||||
legendParts.push(`MGM: ${detail.mgm.temp}${detail.temp_symbol}`);
|
||||
}
|
||||
if (!hasMgmHourly && debMax != null && omMax != null && Math.abs(offset) > 0.3) {
|
||||
const sign = offset > 0 ? "+" : "";
|
||||
legendParts.push(`DEB 偏移 ${sign}${offset.toFixed(1)}${detail.temp_symbol} vs OM`);
|
||||
}
|
||||
if (hasMgmHourly) {
|
||||
legendParts.push("已使用 MGM 小时预报替代 DEB 曲线");
|
||||
}
|
||||
if (detail.trend?.recent?.length) {
|
||||
const recentText = [...detail.trend.recent]
|
||||
.slice(0, 4)
|
||||
.reverse()
|
||||
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
|
||||
.join(" -> ");
|
||||
legendParts.push(`METAR: ${recentText}`);
|
||||
}
|
||||
|
||||
return {
|
||||
datasets: {
|
||||
debFuture,
|
||||
debPast,
|
||||
hasMgmHourly,
|
||||
metarPoints,
|
||||
mgmHourlyPoints,
|
||||
mgmPoints,
|
||||
offset,
|
||||
temps,
|
||||
},
|
||||
legendText: legendParts.join(" | "),
|
||||
max,
|
||||
min,
|
||||
times,
|
||||
};
|
||||
}
|
||||
|
||||
export function getProbabilityView(detail: CityDetail, targetDate?: string | null) {
|
||||
const date = targetDate || detail.local_date;
|
||||
if (date === detail.local_date) {
|
||||
return {
|
||||
mu: detail.probabilities?.mu ?? null,
|
||||
probabilities: detail.probabilities?.distribution || [],
|
||||
};
|
||||
}
|
||||
|
||||
const daily = detail.multi_model_daily?.[date];
|
||||
return {
|
||||
mu: daily?.deb?.prediction ?? null,
|
||||
probabilities: daily?.probabilities || [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getModelView(detail: CityDetail, targetDate?: string | null) {
|
||||
const date = targetDate || detail.local_date;
|
||||
const daily = detail.multi_model_daily?.[date];
|
||||
if (daily) {
|
||||
return {
|
||||
deb: daily.deb?.prediction ?? null,
|
||||
models: daily.models || {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
deb: detail.deb?.prediction ?? null,
|
||||
models: detail.multi_model || {},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAiAnalysis(analysis: CityDetail["ai_analysis"]) {
|
||||
const fallback = {
|
||||
bullets: [] as string[],
|
||||
summary: "",
|
||||
};
|
||||
|
||||
if (!analysis) return fallback;
|
||||
|
||||
if (typeof analysis === "string") {
|
||||
return {
|
||||
bullets: [],
|
||||
summary: analysis.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const structured = analysis as AiAnalysisStructured;
|
||||
return {
|
||||
bullets: Array.isArray(structured.highlights)
|
||||
? structured.highlights
|
||||
: Array.isArray(structured.points)
|
||||
? structured.points
|
||||
: [],
|
||||
summary: structured.summary || structured.text || structured.message || "",
|
||||
};
|
||||
}
|
||||
|
||||
export function pickAnkaraNearbyStations(stations: NearbyStation[]) {
|
||||
const preferredNames = [
|
||||
"Airport (MGM/17128)",
|
||||
"Ankara (Bölge/Center)",
|
||||
"Ankara (Bolge/Center)",
|
||||
"Etimesgut",
|
||||
"Pursaklar",
|
||||
"Cubuk",
|
||||
"Çubuk",
|
||||
"Kalecik",
|
||||
];
|
||||
|
||||
const picks = preferredNames
|
||||
.map((name) => stations.find((station) => station?.name === name))
|
||||
.filter(Boolean) as NearbyStation[];
|
||||
|
||||
return picks.length ? picks : stations;
|
||||
}
|
||||
|
||||
export function getFutureSlice(detail: CityDetail, dateStr: string) {
|
||||
const hourly = detail.hourly_next_48h || {};
|
||||
const times = hourly.times || [];
|
||||
const slice: Array<{
|
||||
cloudCover: number | null;
|
||||
dewPoint: number | null;
|
||||
label: string;
|
||||
precipProb: number | null;
|
||||
pressure: number | null;
|
||||
radiation: number | null;
|
||||
temp: number | null;
|
||||
time: string;
|
||||
windDir: number | null;
|
||||
windSpeed: number | null;
|
||||
}> = [];
|
||||
|
||||
for (let index = 0; index < times.length; index += 1) {
|
||||
const timestamp = times[index];
|
||||
if (!timestamp || !String(timestamp).startsWith(dateStr)) continue;
|
||||
|
||||
slice.push({
|
||||
cloudCover: hourly.cloud_cover?.[index] ?? null,
|
||||
dewPoint: hourly.dew_point?.[index] ?? null,
|
||||
label: String(timestamp).split("T")[1]?.slice(0, 5) || timestamp,
|
||||
precipProb: hourly.precipitation_probability?.[index] ?? null,
|
||||
pressure: hourly.pressure_msl?.[index] ?? null,
|
||||
radiation: hourly.radiation?.[index] ?? null,
|
||||
temp: hourly.temps?.[index] ?? null,
|
||||
time: timestamp,
|
||||
windDir: hourly.wind_direction_10m?.[index] ?? null,
|
||||
windSpeed: hourly.wind_speed_10m?.[index] ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return slice;
|
||||
}
|
||||
|
||||
function trendBucketFromDir(direction?: number | null) {
|
||||
const value = Number(direction);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
if (value >= 135 && value <= 240) return "southerly";
|
||||
if (value >= 290 || value <= 45) return "northerly";
|
||||
if (value > 45 && value < 135) return "easterly";
|
||||
return "westerly";
|
||||
}
|
||||
|
||||
function bucketLabel(bucket: string | null) {
|
||||
return (
|
||||
{
|
||||
southerly: "南 / 西南风",
|
||||
northerly: "北 / 西北风",
|
||||
easterly: "东风",
|
||||
westerly: "西风",
|
||||
}[bucket || ""] || "风向不明"
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDelta(value: number | null | undefined, suffix = "") {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return "--";
|
||||
const sign = numeric > 0 ? "+" : "";
|
||||
return `${sign}${numeric.toFixed(1)}${suffix}`;
|
||||
}
|
||||
|
||||
function getForecastTextForDate(detail: CityDetail, dateStr: string) {
|
||||
const periods = detail.source_forecasts?.weather_gov?.forecast_periods || [];
|
||||
return periods.filter((period) =>
|
||||
String(period.start_time || "").startsWith(dateStr),
|
||||
);
|
||||
}
|
||||
|
||||
export function computeFrontTrendSignal(detail: CityDetail, dateStr: string) {
|
||||
const slice = getFutureSlice(detail, dateStr);
|
||||
const currentTemp = Number(detail.current?.temp);
|
||||
const currentDew = Number(detail.current?.dewpoint);
|
||||
|
||||
if (!slice.length) {
|
||||
return {
|
||||
confidence: "low",
|
||||
label: "监控中",
|
||||
metrics: [] as Array<{
|
||||
label: string;
|
||||
note: string;
|
||||
tone?: string;
|
||||
value: string;
|
||||
}>,
|
||||
precipMax: 0,
|
||||
score: 0,
|
||||
summary: "未来 48 小时结构化数据不足,暂时只保留基础监控。",
|
||||
weatherGovPeriods: [] as ReturnType<typeof getForecastTextForDate>,
|
||||
};
|
||||
}
|
||||
|
||||
const first = slice[0];
|
||||
const last = slice[slice.length - 1];
|
||||
const firstTemp = Number.isFinite(Number(first.temp)) ? Number(first.temp) : currentTemp;
|
||||
const lastTemp = Number.isFinite(Number(last.temp)) ? Number(last.temp) : firstTemp;
|
||||
const tempDelta =
|
||||
Number.isFinite(firstTemp) && Number.isFinite(lastTemp) ? lastTemp - firstTemp : 0;
|
||||
const firstDew = Number.isFinite(Number(first.dewPoint))
|
||||
? Number(first.dewPoint)
|
||||
: currentDew;
|
||||
const lastDew = Number.isFinite(Number(last.dewPoint))
|
||||
? Number(last.dewPoint)
|
||||
: firstDew;
|
||||
const dewDelta =
|
||||
Number.isFinite(firstDew) && Number.isFinite(lastDew) ? lastDew - firstDew : 0;
|
||||
const firstPressure = Number.isFinite(Number(first.pressure))
|
||||
? Number(first.pressure)
|
||||
: null;
|
||||
const lastPressure = Number.isFinite(Number(last.pressure))
|
||||
? Number(last.pressure)
|
||||
: firstPressure;
|
||||
const pressureDelta =
|
||||
Number.isFinite(Number(firstPressure)) && Number.isFinite(Number(lastPressure))
|
||||
? Number(lastPressure) - Number(firstPressure)
|
||||
: 0;
|
||||
const firstCloud = Number.isFinite(Number(first.cloudCover))
|
||||
? Number(first.cloudCover)
|
||||
: null;
|
||||
const lastCloud = Number.isFinite(Number(last.cloudCover))
|
||||
? Number(last.cloudCover)
|
||||
: firstCloud;
|
||||
const cloudDelta =
|
||||
Number.isFinite(Number(firstCloud)) && Number.isFinite(Number(lastCloud))
|
||||
? Number(lastCloud) - Number(firstCloud)
|
||||
: 0;
|
||||
const precipMax = slice.reduce(
|
||||
(max, point) => Math.max(max, Number(point.precipProb) || 0),
|
||||
0,
|
||||
);
|
||||
const firstBucket = trendBucketFromDir(first.windDir);
|
||||
const lastBucket = trendBucketFromDir(last.windDir);
|
||||
const weatherGovPeriods = getForecastTextForDate(detail, dateStr);
|
||||
const weatherGovText = weatherGovPeriods
|
||||
.map(
|
||||
(period) =>
|
||||
`${period.short_forecast || ""} ${period.detailed_forecast || ""}`.toLowerCase(),
|
||||
)
|
||||
.join(" ");
|
||||
|
||||
let warmScore = 0;
|
||||
let coldScore = 0;
|
||||
if (tempDelta >= 2) warmScore += 24;
|
||||
else if (tempDelta >= 0.8) warmScore += 12;
|
||||
if (tempDelta <= -2) coldScore += 24;
|
||||
else if (tempDelta <= -0.8) coldScore += 12;
|
||||
if (dewDelta >= 1.2) warmScore += 14;
|
||||
if (dewDelta <= -1.2) coldScore += 10;
|
||||
if (pressureDelta >= 1.2) coldScore += 16;
|
||||
if (pressureDelta <= -1.0) warmScore += 8;
|
||||
if (lastBucket === "southerly") warmScore += 14;
|
||||
if (firstBucket !== lastBucket && lastBucket === "southerly") warmScore += 10;
|
||||
if (lastBucket === "northerly") coldScore += 14;
|
||||
if (firstBucket !== lastBucket && lastBucket === "northerly") coldScore += 10;
|
||||
if (cloudDelta >= 15 && tempDelta >= 0) warmScore += 6;
|
||||
if (cloudDelta >= 15 && tempDelta < 0) coldScore += 8;
|
||||
if (precipMax >= 40) coldScore += 8;
|
||||
if (
|
||||
weatherGovText.includes("cold front") ||
|
||||
weatherGovText.includes("temperatures falling")
|
||||
) {
|
||||
coldScore += 18;
|
||||
}
|
||||
if (weatherGovText.includes("warm front") || weatherGovText.includes("warmer")) {
|
||||
warmScore += 18;
|
||||
}
|
||||
if (weatherGovText.includes("thunder") || weatherGovText.includes("snow")) {
|
||||
coldScore += 8;
|
||||
}
|
||||
|
||||
const score = Math.max(-100, Math.min(100, warmScore - coldScore));
|
||||
const label =
|
||||
score >= 18
|
||||
? "暖平流 / 暖锋倾向"
|
||||
: score <= -18
|
||||
? "冷平流 / 冷锋倾向"
|
||||
: "监控中";
|
||||
const confidence =
|
||||
Math.abs(score) >= 45 ? "high" : Math.abs(score) >= 22 ? "medium" : "low";
|
||||
|
||||
return {
|
||||
confidence,
|
||||
label,
|
||||
metrics: [
|
||||
{
|
||||
label: "温度变化",
|
||||
note: "Open-Meteo 未来小时温度变化",
|
||||
tone: tempDelta >= 0.8 ? "warm" : tempDelta <= -0.8 ? "cold" : "",
|
||||
value: formatDelta(tempDelta, detail.temp_symbol),
|
||||
},
|
||||
{
|
||||
label: "露点变化",
|
||||
note: "露点上升更偏向暖湿平流",
|
||||
tone: dewDelta >= 0.8 ? "warm" : dewDelta <= -0.8 ? "cold" : "",
|
||||
value: formatDelta(dewDelta, detail.temp_symbol),
|
||||
},
|
||||
{
|
||||
label: "气压变化",
|
||||
note: "气压回升更偏向冷空气压入",
|
||||
tone: pressureDelta >= 1 ? "cold" : pressureDelta <= -1 ? "warm" : "",
|
||||
value: formatDelta(pressureDelta, " hPa"),
|
||||
},
|
||||
{
|
||||
label: "风向演变",
|
||||
note: "关注是否转南风或转北风",
|
||||
value: `${bucketLabel(firstBucket)} -> ${bucketLabel(lastBucket)}`,
|
||||
},
|
||||
{
|
||||
label: "降水概率",
|
||||
note: "weather.gov / Open-Meteo 降水提示",
|
||||
tone: precipMax >= 50 ? "cold" : "",
|
||||
value: `${Math.round(precipMax)}%`,
|
||||
},
|
||||
{
|
||||
label: "云量变化",
|
||||
note: "云量抬升但未降温,常见于暖平流前段",
|
||||
tone:
|
||||
cloudDelta >= 15 && tempDelta >= 0
|
||||
? "warm"
|
||||
: cloudDelta >= 15 && tempDelta < 0
|
||||
? "cold"
|
||||
: "",
|
||||
value: formatDelta(cloudDelta, "%"),
|
||||
},
|
||||
],
|
||||
precipMax,
|
||||
score,
|
||||
summary:
|
||||
label === "暖平流 / 暖锋倾向"
|
||||
? "风向更偏南 / 西南,露点与温度整体抬升,未来 6-48 小时偏向暖平流。"
|
||||
: label === "冷平流 / 冷锋倾向"
|
||||
? "温度下滑、气压回升或风向转北,未来 6-48 小时更像冷锋或冷平流压制。"
|
||||
: detail.name !== "ankara" && Boolean(detail.source_forecasts?.meteoblue)
|
||||
? "结构化来源以 weather.gov、Open-Meteo、Meteoblue 为主,用于判断未来 6-48 小时冷暖平流趋势。"
|
||||
: "结构化来源以 weather.gov 与 Open-Meteo 为主,用于判断未来 6-48 小时冷暖平流趋势。",
|
||||
weatherGovPeriods,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFutureModalView(detail: CityDetail, dateStr: string) {
|
||||
const forecastEntry =
|
||||
detail.forecast?.daily?.find((item) => item.date === dateStr) || null;
|
||||
const dailyModel = detail.multi_model_daily?.[dateStr] || {};
|
||||
const probabilities = dailyModel.probabilities || [];
|
||||
const totalProbability = probabilities.reduce((sum, item) => {
|
||||
const probability = Number(item.probability);
|
||||
return Number.isFinite(probability) ? sum + probability : sum;
|
||||
}, 0);
|
||||
const weightedProbability = probabilities.reduce((sum, item) => {
|
||||
const value = Number(item.value);
|
||||
const probability = Number(item.probability);
|
||||
if (!Number.isFinite(value) || !Number.isFinite(probability)) {
|
||||
return sum;
|
||||
}
|
||||
return sum + value * probability;
|
||||
}, 0);
|
||||
const mu = totalProbability > 0 ? weightedProbability / totalProbability : null;
|
||||
const deb = dailyModel.deb?.prediction ?? forecastEntry?.max_temp ?? null;
|
||||
|
||||
return {
|
||||
deb,
|
||||
forecastEntry,
|
||||
front: computeFrontTrendSignal(detail, dateStr),
|
||||
models: dailyModel.models || {},
|
||||
mu: Number.isFinite(Number(mu)) ? Number(mu) : null,
|
||||
probabilities,
|
||||
slice: getFutureSlice(detail, dateStr),
|
||||
};
|
||||
}
|
||||
|
||||
export function getShortTermNowcastLines(detail: CityDetail, dateStr: string) {
|
||||
const slice = getFutureSlice(detail, dateStr);
|
||||
if (dateStr !== detail.local_date) {
|
||||
const afternoon = slice.filter((point) => {
|
||||
const hour = Number.parseInt(String(point.label).split(":")[0], 10);
|
||||
return Number.isFinite(hour) && hour >= 12 && hour <= 18;
|
||||
});
|
||||
const target = afternoon.length ? afternoon : slice;
|
||||
if (!target.length) {
|
||||
return [
|
||||
["目标日期", dateStr],
|
||||
["峰值窗口", "暂无足够的小时级 forecast 数据,无法生成目标日午后峰值窗口判断。"],
|
||||
] as const;
|
||||
}
|
||||
|
||||
const maxIndex = target.reduce((bestIndex, point, index, array) => {
|
||||
const temp = Number(point.temp);
|
||||
const bestTemp = Number(array[bestIndex]?.temp);
|
||||
if (!Number.isFinite(temp)) return bestIndex;
|
||||
if (!Number.isFinite(bestTemp) || temp > bestTemp) return index;
|
||||
return bestIndex;
|
||||
}, 0);
|
||||
|
||||
const peakSlice = target.slice(
|
||||
Math.max(0, maxIndex - 1),
|
||||
Math.min(target.length, maxIndex + 2),
|
||||
);
|
||||
const start = peakSlice[0];
|
||||
const end = peakSlice[peakSlice.length - 1];
|
||||
const peakPoint = target[maxIndex] || end;
|
||||
const startTemp = Number(start.temp);
|
||||
const endTemp = Number(end.temp);
|
||||
const startDew = Number(start.dewPoint);
|
||||
const endDew = Number(end.dewPoint);
|
||||
const startPressure = Number(start.pressure);
|
||||
const endPressure = Number(end.pressure);
|
||||
const precipValues = peakSlice
|
||||
.map((point) => Number(point.precipProb))
|
||||
.filter(Number.isFinite);
|
||||
const cloudValues = peakSlice
|
||||
.map((point) => Number(point.cloudCover))
|
||||
.filter(Number.isFinite);
|
||||
const maxPrecip = precipValues.length ? Math.max(...precipValues) : 0;
|
||||
const maxCloud = cloudValues.length ? Math.max(...cloudValues) : 0;
|
||||
|
||||
return [
|
||||
["目标日期", dateStr],
|
||||
["峰值窗口", `${start.label} - ${end.label}(优先取 12:00-18:00)`],
|
||||
[
|
||||
"峰值预估",
|
||||
`${Number.isFinite(Number(peakPoint.temp)) ? Number(peakPoint.temp).toFixed(1) : "--"}${detail.temp_symbol} @ ${peakPoint.label || "--"}`,
|
||||
],
|
||||
[
|
||||
"窗口温度",
|
||||
`${Number.isFinite(startTemp) ? startTemp.toFixed(1) : "--"}${detail.temp_symbol} -> ${Number.isFinite(endTemp) ? endTemp.toFixed(1) : "--"}${detail.temp_symbol}(${formatDelta(endTemp - startTemp, detail.temp_symbol)})`,
|
||||
],
|
||||
["露点变化", `${formatDelta(endDew - startDew, detail.temp_symbol)},用于判断午后暖湿输送是否增强。`],
|
||||
[
|
||||
"风向演变",
|
||||
`${bucketLabel(trendBucketFromDir(start.windDir))} -> ${bucketLabel(trendBucketFromDir(end.windDir))},关注峰值前后是否转南风或回摆北风。`,
|
||||
],
|
||||
["气压变化", `${formatDelta(endPressure - startPressure, " hPa")},上升更偏向冷空气压入。`],
|
||||
["降水 / 云量", `${Math.round(maxPrecip)}% / ${Math.round(maxCloud)}%,用于判断峰值时段是否受云系压制。`],
|
||||
] as const;
|
||||
}
|
||||
|
||||
const recent = Array.isArray(detail.metar_recent_obs)
|
||||
? detail.metar_recent_obs.slice(-4)
|
||||
: [];
|
||||
const nearby = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : [];
|
||||
const sourceLabel = detail.name === "ankara" ? "MGM 周边站" : "METAR 周边站";
|
||||
const currentTemp = Number(detail.current?.temp);
|
||||
const recentTemps = recent
|
||||
.map((point) => Number(point.temp))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
const baseline = recentTemps.length ? recentTemps[0] : currentTemp;
|
||||
const shortDelta =
|
||||
Number.isFinite(currentTemp) && Number.isFinite(baseline)
|
||||
? currentTemp - baseline
|
||||
: 0;
|
||||
let nearbyLead: { diff: number; name: string; temp: number } | null = null;
|
||||
|
||||
for (const station of nearby) {
|
||||
const temp = Number(station.temp);
|
||||
if (!Number.isFinite(temp) || !Number.isFinite(currentTemp)) continue;
|
||||
const diff = temp - currentTemp;
|
||||
if (!nearbyLead || Math.abs(diff) > Math.abs(nearbyLead.diff)) {
|
||||
nearbyLead = {
|
||||
diff,
|
||||
name: station.name || station.icao || "周边站",
|
||||
temp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const rows: Array<readonly [string, string]> = [
|
||||
["当前主站", `${detail.current?.temp ?? "--"}${detail.temp_symbol} @ ${detail.current?.obs_time || "--"}`],
|
||||
["原始 METAR", detail.current?.raw_metar || "暂无"],
|
||||
["近 0-2 小时", `${formatDelta(shortDelta, detail.temp_symbol)},依据最近 METAR 序列判断短时动量。`],
|
||||
[sourceLabel, `${nearby.length} 个站点参与邻近监控。`],
|
||||
];
|
||||
|
||||
if (nearbyLead) {
|
||||
const tone =
|
||||
nearbyLead.diff > 0 ? "偏暖" : nearbyLead.diff < 0 ? "偏冷" : "持平";
|
||||
rows.push([
|
||||
"领先站",
|
||||
`${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol},相对主站 ${formatDelta(nearbyLead.diff, detail.temp_symbol)}(${tone})。`,
|
||||
]);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function getHistorySummary(
|
||||
history: HistoryPoint[],
|
||||
cityLocalDate?: string | null,
|
||||
) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setHours(0, 0, 0, 0);
|
||||
cutoff.setDate(cutoff.getDate() - 14);
|
||||
|
||||
const recentData = history.filter((row) => {
|
||||
if (!row?.date) return false;
|
||||
const rowDate = new Date(`${row.date}T00:00:00`);
|
||||
return !Number.isNaN(rowDate.getTime()) && rowDate >= cutoff;
|
||||
});
|
||||
|
||||
const settledData = recentData.filter((row) => {
|
||||
if (!row?.date) return false;
|
||||
return cityLocalDate
|
||||
? row.date < cityLocalDate
|
||||
: row.date < new Date().toISOString().slice(0, 10);
|
||||
});
|
||||
|
||||
let hits = 0;
|
||||
const debErrors: number[] = [];
|
||||
settledData.forEach((row) => {
|
||||
if (row.actual != null && row.deb != null) {
|
||||
debErrors.push(Math.abs(row.actual - row.deb));
|
||||
if (Math.round(row.actual) === Math.round(row.deb)) {
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
dates: recentData.map((row) => row.date),
|
||||
debMae: debErrors.length
|
||||
? Number(
|
||||
(
|
||||
debErrors.reduce((sum, value) => sum + value, 0) / debErrors.length
|
||||
).toFixed(1),
|
||||
)
|
||||
: null,
|
||||
debs: recentData.map((row) => row.deb),
|
||||
hitRate: debErrors.length
|
||||
? Number(((hits / debErrors.length) * 100).toFixed(0))
|
||||
: null,
|
||||
mgms: recentData.map((row) => row.mgm ?? null),
|
||||
recentData,
|
||||
settledCount: settledData.length,
|
||||
actuals: recentData.map((row) => row.actual),
|
||||
};
|
||||
}
|
||||
|
||||
export function getCityProfileStats(detail: CityDetail) {
|
||||
const risk = detail.risk || {};
|
||||
const current = detail.current || {};
|
||||
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
|
||||
|
||||
return [
|
||||
{
|
||||
label: "结算机场",
|
||||
value: risk.airport && risk.icao ? `${risk.airport} (${risk.icao})` : "暂无档案",
|
||||
},
|
||||
{
|
||||
label: "站点距离",
|
||||
value:
|
||||
risk.distance_km != null && Number.isFinite(Number(risk.distance_km))
|
||||
? `${risk.distance_km} km`
|
||||
: "未标注",
|
||||
},
|
||||
{
|
||||
label: "观测更新",
|
||||
value: current.obs_time || detail.updated_at || "未提供",
|
||||
},
|
||||
{
|
||||
label: "周边站点",
|
||||
value: nearbyCount > 0 ? `${nearbyCount} 个参与监控` : "暂无周边站",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getSettlementRiskNarrative(detail: CityDetail) {
|
||||
const risk = detail.risk || {};
|
||||
const lines: string[] = [];
|
||||
|
||||
if (risk.warning) {
|
||||
lines.push(`当前主要风险是:${risk.warning}`);
|
||||
}
|
||||
|
||||
if (risk.distance_km != null) {
|
||||
if (risk.distance_km >= 60) {
|
||||
lines.push("结算机场与城市核心区域距离偏大,盘面温度与结算值可能出现明显背离。");
|
||||
} else if (risk.distance_km >= 25) {
|
||||
lines.push("结算机场与城区存在可感知距离,午后峰值和夜间降温节奏需要优先看机场站。");
|
||||
} else {
|
||||
lines.push("结算机场距离较近,城市体感与结算温度通常更同步。");
|
||||
}
|
||||
}
|
||||
|
||||
if (detail.name === "ankara") {
|
||||
lines.push("Ankara 需要重点看 LTAC / Esenboğa 与 MGM 周边站联动,不能只看城区体感。");
|
||||
}
|
||||
|
||||
if (detail.current?.obs_age_min != null) {
|
||||
if (detail.current.obs_age_min >= 45) {
|
||||
lines.push(`当前 METAR 已有 ${detail.current.obs_age_min} 分钟时滞,临近判断要结合周边站而不是只看主站快照。`);
|
||||
} else {
|
||||
lines.push("当前主站观测较新,短时判断可以把主站温度作为主要锚点。");
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function getClimateDrivers(detail: CityDetail) {
|
||||
const drivers: Array<{ label: string; text: string }> = [];
|
||||
const lat = Math.abs(Number(detail.lat));
|
||||
const current = detail.current || {};
|
||||
const temp = Number(current.temp);
|
||||
const dewPoint = Number(current.dewpoint);
|
||||
const humidity = Number(current.humidity);
|
||||
const windSpeed = Number(current.wind_speed_kt);
|
||||
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
|
||||
|
||||
if (lat >= 50) {
|
||||
drivers.push({
|
||||
label: "高纬冷空气",
|
||||
text: "这座城市处在较高纬度,气温更容易受冷空气南下、短波槽和日照角度变化影响,波动通常偏快。",
|
||||
});
|
||||
} else if (lat >= 35) {
|
||||
drivers.push({
|
||||
label: "中纬度西风带",
|
||||
text: "这座城市主要受中纬度西风带和锋面活动控制,升温或降温往往来自气团切换,而不是单一的日照变化。",
|
||||
});
|
||||
} else if (lat >= 20) {
|
||||
drivers.push({
|
||||
label: "副热带高压",
|
||||
text: "这座城市更容易受到副热带高压、晴空辐射和低层暖平流影响,午后冲高能力通常比高纬城市更强。",
|
||||
});
|
||||
} else {
|
||||
drivers.push({
|
||||
label: "热带水汽与对流",
|
||||
text: "这座城市更偏热带环境,温度与体感常受水汽输送、云对流和阵雨触发影响,不完全由晴空辐射主导。",
|
||||
});
|
||||
}
|
||||
|
||||
if (Number.isFinite(windSpeed) && windSpeed >= 12) {
|
||||
drivers.push({
|
||||
label: "平流输送",
|
||||
text: `当前风速约 ${windSpeed}kt,说明低层输送比较明显,盘面短时方向更容易被外来气团带动。`,
|
||||
});
|
||||
} else if (detail.trend?.is_dead_market) {
|
||||
drivers.push({
|
||||
label: "本地辐射主导",
|
||||
text: "近期更像本地辐射和地表热量收支在主导,若无新气团介入,温度节奏通常更平滑。",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
Number.isFinite(temp) &&
|
||||
Number.isFinite(dewPoint) &&
|
||||
temp - dewPoint <= 3
|
||||
) {
|
||||
drivers.push({
|
||||
label: "湿度与云量约束",
|
||||
text: "当前温度和露点接近,说明低层湿度较高。午后峰值容易受云量和降水触发抑制。",
|
||||
});
|
||||
} else if (Number.isFinite(humidity) && humidity >= 70) {
|
||||
drivers.push({
|
||||
label: "湿层偏厚",
|
||||
text: "相对湿度偏高,说明局地升温效率会受到水汽和云层反馈影响,冲高空间要比干空气场景更小心。",
|
||||
});
|
||||
} else {
|
||||
drivers.push({
|
||||
label: "干暖边界层",
|
||||
text: "低层空气相对偏干,晴空时段的升温效率通常更高,午后冲顶更依赖辐射和风向切换。",
|
||||
});
|
||||
}
|
||||
|
||||
if (nearbyCount >= 4) {
|
||||
drivers.push({
|
||||
label: "局地差异",
|
||||
text: "周边可用站点较多,说明地形、城区热岛或下垫面差异可能明显,结算站与城区体感需要分开看。",
|
||||
});
|
||||
}
|
||||
|
||||
return drivers;
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
// --- Polymarket Market Types ---
|
||||
|
||||
export interface MarketBook {
|
||||
best_bid: number;
|
||||
best_ask: number;
|
||||
bid_levels: [number, number][]; // [price, size]
|
||||
ask_levels: [number, number][]; // [price, size]
|
||||
}
|
||||
|
||||
export interface MarketToken {
|
||||
outcome: string;
|
||||
token_id: string;
|
||||
implied_probability: number;
|
||||
buy_price: number;
|
||||
sell_price: number;
|
||||
midpoint: number;
|
||||
last_trade_price: number;
|
||||
book?: MarketBook;
|
||||
}
|
||||
|
||||
export interface Trade {
|
||||
id: string;
|
||||
price: number;
|
||||
size: number;
|
||||
side: "buy" | "sell";
|
||||
timestamp: string;
|
||||
timestamp_iso?: string | null;
|
||||
outcome?: string | null;
|
||||
asset?: string | null;
|
||||
transaction_hash?: string | null;
|
||||
}
|
||||
|
||||
export interface MarketSnapshot {
|
||||
id: string;
|
||||
question: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
event_slug: string;
|
||||
condition_id: string;
|
||||
target_date: string;
|
||||
active: boolean;
|
||||
closed: boolean;
|
||||
archived: boolean;
|
||||
enable_order_book: boolean;
|
||||
liquidity: number;
|
||||
volume: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
tokens: MarketToken[];
|
||||
recent_trades: Trade[];
|
||||
}
|
||||
|
||||
export interface MarketPlatformData {
|
||||
city: string;
|
||||
target_date: string;
|
||||
fetched_at: string;
|
||||
market_count: number;
|
||||
markets: MarketSnapshot[];
|
||||
websocket: {
|
||||
market_url: string;
|
||||
asset_ids: string[];
|
||||
condition_ids: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// --- Official Weather Types (Aviation) ---
|
||||
|
||||
export interface METARObservation {
|
||||
source: string;
|
||||
icao: string;
|
||||
station_name: string;
|
||||
timestamp: string;
|
||||
observation_time: string;
|
||||
current: {
|
||||
temp: number;
|
||||
max_temp_so_far: number;
|
||||
max_temp_time: string;
|
||||
dewpoint: number;
|
||||
wind_speed_kt: number;
|
||||
wind_dir: number;
|
||||
visibility_mi: string | number;
|
||||
wx_desc: string | null;
|
||||
altimeter: number;
|
||||
clouds: any[];
|
||||
};
|
||||
recent_temps: number[];
|
||||
today_obs: any[];
|
||||
recent_obs: any[];
|
||||
unit: "celsius" | "fahrenheit";
|
||||
}
|
||||
|
||||
export interface AviationWeatherData {
|
||||
available: boolean;
|
||||
source: string;
|
||||
icao: string;
|
||||
observation: METARObservation;
|
||||
}
|
||||
|
||||
// --- Official Weather Types (Weather.gov) ---
|
||||
|
||||
export interface WeatherGovPeriod {
|
||||
name: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
is_daytime?: boolean;
|
||||
temperature: number;
|
||||
temperature_unit: string;
|
||||
wind_speed?: string;
|
||||
wind_direction?: string;
|
||||
short_forecast?: string;
|
||||
detailed_forecast?: string;
|
||||
}
|
||||
|
||||
export interface WeatherGovAlert {
|
||||
id: string;
|
||||
event: string;
|
||||
severity: string;
|
||||
certainty: string;
|
||||
urgency: string;
|
||||
headline: string;
|
||||
onset: string;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
export interface WeatherGovData {
|
||||
available: boolean;
|
||||
source: string;
|
||||
city: string;
|
||||
grid: any;
|
||||
nearest_station: {
|
||||
station_identifier?: string;
|
||||
name?: string;
|
||||
timezone?: string;
|
||||
elevation_m?: number | null;
|
||||
} | null;
|
||||
latest_observation: {
|
||||
station_identifier?: string;
|
||||
temperature_c?: number | null;
|
||||
dewpoint_c?: number | null;
|
||||
wind_direction_deg?: number | null;
|
||||
wind_speed_kmh?: number | null;
|
||||
text_description?: string | null;
|
||||
timestamp?: string;
|
||||
} | null;
|
||||
stations: any[];
|
||||
forecast_periods: WeatherGovPeriod[];
|
||||
hourly_periods: WeatherGovPeriod[];
|
||||
active_alerts: WeatherGovAlert[];
|
||||
}
|
||||
|
||||
export interface OfficialWeatherData {
|
||||
city: string;
|
||||
fetched_at: string;
|
||||
aviation_weather?: AviationWeatherData;
|
||||
weather_gov?: WeatherGovData;
|
||||
}
|
||||
|
||||
// --- City List Types ---
|
||||
|
||||
export interface CityInfo {
|
||||
name: string;
|
||||
display_name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
risk_level: "low" | "medium" | "high";
|
||||
risk_emoji: string;
|
||||
airport: string;
|
||||
icao: string;
|
||||
temp_unit: "celsius" | "fahrenheit";
|
||||
is_major: boolean;
|
||||
}
|
||||
|
||||
export interface CitiesResponse {
|
||||
cities: CityInfo[];
|
||||
}
|
||||
|
||||
// --- Full City Analysis Types ---
|
||||
|
||||
export interface ModelComparison {
|
||||
[key: string]: number | undefined;
|
||||
"Open-Meteo"?: number;
|
||||
ECMWF?: number;
|
||||
GFS?: number;
|
||||
ICON?: number;
|
||||
GEM?: number;
|
||||
JMA?: number;
|
||||
MGM?: number;
|
||||
NWS?: number;
|
||||
Meteoblue?: number;
|
||||
}
|
||||
|
||||
export interface DEBAnalysis {
|
||||
prediction: number | null;
|
||||
weights_info?: string;
|
||||
}
|
||||
|
||||
export interface TrendInfo {
|
||||
direction: string;
|
||||
recent: Array<{ time: string; temp: number }>;
|
||||
is_cooling: boolean;
|
||||
is_dead_market: boolean;
|
||||
}
|
||||
|
||||
export interface PeakInfo {
|
||||
hours: string[];
|
||||
first_h: number;
|
||||
last_h: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface CityAnalysis {
|
||||
name: string;
|
||||
display_name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
temp_symbol: string;
|
||||
local_time: string;
|
||||
local_date: string;
|
||||
risk: {
|
||||
level: string;
|
||||
emoji: string;
|
||||
airport: string;
|
||||
icao: string;
|
||||
distance_km: number;
|
||||
warning: string;
|
||||
};
|
||||
current: {
|
||||
temp: number | null;
|
||||
max_so_far: number | null;
|
||||
max_temp_time: string;
|
||||
wu_settlement: number | null;
|
||||
obs_time: string;
|
||||
obs_age_min: number | null;
|
||||
wind_speed_kt: number | null;
|
||||
wind_dir: number | null;
|
||||
humidity: number | null;
|
||||
cloud_desc: string;
|
||||
clouds_raw: Array<{ cover: string; base: number | null }>;
|
||||
visibility_mi: number | null;
|
||||
wx_desc: string | null;
|
||||
raw_metar?: string | null;
|
||||
report_time?: string | null;
|
||||
receipt_time?: string | null;
|
||||
obs_time_epoch?: number | null;
|
||||
};
|
||||
mgm?: {
|
||||
temp?: number | null;
|
||||
time?: string;
|
||||
today_high?: number | null;
|
||||
today_low?: number | null;
|
||||
};
|
||||
forecast: {
|
||||
today_high: number | null;
|
||||
daily: any[];
|
||||
};
|
||||
multi_model: ModelComparison;
|
||||
deb: DEBAnalysis;
|
||||
probabilities: {
|
||||
mu?: number | null;
|
||||
distribution?: any[];
|
||||
};
|
||||
hourly?: any;
|
||||
metar_recent_obs?: any[];
|
||||
trend?: TrendInfo;
|
||||
peak?: PeakInfo;
|
||||
ai_analysis: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// --- Aggregated Detail Types ---
|
||||
|
||||
export interface MarketScan {
|
||||
available: boolean;
|
||||
reason: string | null;
|
||||
primary_market: any | null;
|
||||
selected_date: string | null;
|
||||
selected_condition_id: string | null;
|
||||
selected_slug: string | null;
|
||||
temperature_bucket: any | null;
|
||||
model_probability: number | null;
|
||||
market_price: number | null;
|
||||
edge_percent: number | null;
|
||||
signal_label: "BUY YES" | "BUY NO" | "MONITOR";
|
||||
confidence: "low" | "medium" | "high";
|
||||
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[];
|
||||
recent_trades: Trade[];
|
||||
websocket: any;
|
||||
}
|
||||
|
||||
export interface CityDetail {
|
||||
city: string;
|
||||
fetched_at: string;
|
||||
overview: {
|
||||
name: string;
|
||||
display_name: string;
|
||||
icao: string;
|
||||
airport: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
local_time: string;
|
||||
local_date: string;
|
||||
temp_symbol: string;
|
||||
current_temp: number | null;
|
||||
deb_prediction: number | null;
|
||||
risk_level: string;
|
||||
risk_warning: string;
|
||||
updated_at: string;
|
||||
};
|
||||
official: {
|
||||
available: boolean;
|
||||
metar: any;
|
||||
weather_gov: WeatherGovData;
|
||||
mgm: any;
|
||||
mgm_nearby: any[];
|
||||
nearby_source?: string;
|
||||
};
|
||||
timeseries: {
|
||||
metar_recent_obs: any[];
|
||||
metar_today_obs: any[];
|
||||
hourly: any;
|
||||
mgm_hourly: any[];
|
||||
forecast_daily: any[];
|
||||
};
|
||||
models: ModelComparison;
|
||||
probabilities: {
|
||||
mu: number | null;
|
||||
distribution: any[];
|
||||
};
|
||||
market_scan: MarketScan;
|
||||
risk: any;
|
||||
ai_analysis: string;
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CitySummary {
|
||||
name: string;
|
||||
display_name: string;
|
||||
icao: string;
|
||||
local_time: string;
|
||||
temp_symbol: string;
|
||||
current: {
|
||||
temp: number | null;
|
||||
obs_time: string;
|
||||
};
|
||||
deb: {
|
||||
prediction: number | null;
|
||||
};
|
||||
risk: {
|
||||
level: string;
|
||||
warning: string;
|
||||
};
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* Utility for merging tailwind classes safely.
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a number as a currency string.
|
||||
*/
|
||||
export function formatCurrency(value: number, decimals: number = 2) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a percentage from midpoint.
|
||||
*/
|
||||
export function formatPercent(value: number) {
|
||||
return (value * 100).toFixed(1) + "%";
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Fahrenheit to Celsius.
|
||||
*/
|
||||
export function fToC(f: number) {
|
||||
return ((f - 32) * 5) / 9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date/time string to HH:MM:SS.
|
||||
*/
|
||||
export function formatTime(date: Date | string) {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return d.toLocaleTimeString("zh-CN", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"leaflet": "^1.9.4",
|
||||
@@ -528,6 +529,11 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.5.12",
|
||||
"resolved": "https://registry.npmmirror.com/@next/env/-/env-15.5.12.tgz",
|
||||
@@ -974,6 +980,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -2334,6 +2351,11 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
||||
},
|
||||
"@next/env": {
|
||||
"version": "15.5.12",
|
||||
"resolved": "https://registry.npmmirror.com/@next/env/-/env-15.5.12.tgz",
|
||||
@@ -2567,6 +2589,14 @@
|
||||
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz",
|
||||
"integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="
|
||||
},
|
||||
"chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"requires": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"leaflet": "^1.9.4",
|
||||
|
||||
@@ -1,47 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PolyWeather — 天气衍生品智能地图</title>
|
||||
<meta name="description" content="PolyWeather 天气衍生品智能地图。实时 METAR、MGM、DEB 融合预报与 AI 分析。">
|
||||
<title>PolyWeather - 天气衍生品智能地图</title>
|
||||
<meta name="description" content="PolyWeather 天气衍生品智能地图,聚合 METAR、MGM、DEB、多模型预报与历史对账分析。">
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
|
||||
<!-- Google Fonts: Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
|
||||
rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=legacy-v19">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- ── Map ── -->
|
||||
<div id="map"></div>
|
||||
|
||||
<!-- ── Header Overlay ── -->
|
||||
<header id="header">
|
||||
<div class="brand">
|
||||
<h1>🌡️ PolyWeather</h1>
|
||||
<h1>PolyWeather</h1>
|
||||
<span class="subtitle">天气衍生品智能分析</span>
|
||||
</div>
|
||||
<button class="info-btn" id="btnShowGuide" title="查看系统技术说明">ℹ️ 技术说明</button>
|
||||
<button class="info-btn" id="btnShowGuide" title="查看系统技术说明">技术说明</button>
|
||||
<div class="live-badge" id="liveBadge">
|
||||
<span class="pulse-dot"></span>
|
||||
<span>实时</span>
|
||||
</div>
|
||||
<button class="refresh-btn" id="refreshAllBtn" title="刷新所有数据">⟳</button>
|
||||
</div>
|
||||
<button class="refresh-btn" id="refreshAllBtn" title="刷新所有数据" aria-label="刷新所有数据">↻</button>
|
||||
</header>
|
||||
|
||||
<!-- ── City List Sidebar (left) ── -->
|
||||
<nav id="cityList" class="city-list">
|
||||
<div class="city-list-header">
|
||||
<span>🏙️ 监控城市</span>
|
||||
<span>监控城市</span>
|
||||
<span class="city-count" id="cityCount">0</span>
|
||||
</div>
|
||||
<div id="cityListItems" class="city-list-items">
|
||||
@@ -49,8 +43,7 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ── Detail Panel (right) ── -->
|
||||
<aside id="panel" class="detail-panel hidden">
|
||||
<aside id="panel" class="detail-panel hidden">
|
||||
<div class="panel-header">
|
||||
<button class="panel-close" id="panelClose">✕</button>
|
||||
<div class="panel-title-area">
|
||||
@@ -58,13 +51,12 @@
|
||||
<div class="panel-meta">
|
||||
<span id="panelRiskBadge" class="risk-badge">—</span>
|
||||
<span id="panelLocalTime" class="local-time">—</span>
|
||||
<button class="history-btn" id="btnShowHistory" title="查看历史记录与准确率">📊 历史对账</button>
|
||||
<button class="history-btn" id="btnShowHistory" title="查看历史记录与准确率">历史对账</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="panelContent" class="panel-body">
|
||||
<!-- ── Temperature Hero ── -->
|
||||
<section class="hero-section">
|
||||
<div class="hero-weather" id="heroWeather"></div>
|
||||
<div class="hero-temp">
|
||||
@@ -74,127 +66,111 @@
|
||||
<div class="hero-max-time" id="heroMaxTime"></div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-item">
|
||||
<span class="label">📍 当前实测</span>
|
||||
<span class="label">当前实测</span>
|
||||
<span class="value" id="heroCurrent">—</span>
|
||||
</div>
|
||||
<div class="hero-item">
|
||||
<span class="label">🎯 WU 结算</span>
|
||||
<span class="label">WU 结算参考</span>
|
||||
<span class="value highlight" id="heroWU">—</span>
|
||||
</div>
|
||||
<div class="hero-item">
|
||||
<span class="label">🧬 DEB 预测</span>
|
||||
<span class="label">DEB 预测</span>
|
||||
<span class="value" id="heroDEB">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-sub" id="heroSub">
|
||||
<!-- obs time, cloud, wind -->
|
||||
</div>
|
||||
<div class="hero-sub" id="heroSub"></div>
|
||||
</section>
|
||||
|
||||
<!-- ── Trend Sparkline ── -->
|
||||
<section class="chart-section">
|
||||
<h3>📊 今日温度走势</h3>
|
||||
<h3>今日温度走势</h3>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="tempChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-legend" id="chartLegend"></div>
|
||||
</section>
|
||||
|
||||
<!-- ── Probability Bars ── -->
|
||||
<section class="prob-section">
|
||||
<h3>🎲 结算概率分布</h3>
|
||||
<div id="probBars" class="prob-bars">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
<h3>结算概率分布</h3>
|
||||
<div id="probBars" class="prob-bars"></div>
|
||||
</section>
|
||||
|
||||
<!-- ── Multi-Model Comparison ── -->
|
||||
<section class="models-section">
|
||||
<h3>🔬 多模型预报</h3>
|
||||
<div id="modelBars" class="model-bars">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
<h3>多模型预报</h3>
|
||||
<div id="modelBars" class="model-bars"></div>
|
||||
</section>
|
||||
|
||||
<!-- ── Forecast Table ── -->
|
||||
<section class="forecast-section">
|
||||
<h3>📅 多日预报</h3>
|
||||
<h3>多日预报</h3>
|
||||
<div id="forecastTable" class="forecast-table"></div>
|
||||
<div class="sun-info" id="sunInfo"></div>
|
||||
</section>
|
||||
|
||||
<!-- ── AI Analysis ── -->
|
||||
<section class="ai-section">
|
||||
<h3>🤖 AI 深度分析</h3>
|
||||
<h3>AI 深度分析</h3>
|
||||
<div id="aiAnalysis" class="ai-box">
|
||||
<span class="ai-placeholder">点击城市后加载...</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ── Risk Profile ── -->
|
||||
<section class="risk-section">
|
||||
<h3>⚠️ 数据偏差风险</h3>
|
||||
<h3>数据偏差风险</h3>
|
||||
<div id="riskInfo" class="risk-info"></div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Loading Overlay ── -->
|
||||
<div id="loading" class="loading-overlay hidden">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>正在获取气象数据,请稍候...</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Technical Guide Modal ── -->
|
||||
<!-- 鈹€鈹€ Technical Guide Modal 鈹€鈹€ -->
|
||||
<div id="guideModal" class="modal-overlay hidden">
|
||||
<div class="modal-content large">
|
||||
<div class="modal-header">
|
||||
<h2>📚 PolyWeather 系统技术说明</h2>
|
||||
<button class="modal-close" id="guideModalClose">✕</button>
|
||||
<h2>📚 PolyWeather 系统技术说明</h2>
|
||||
<button class="modal-close" id="guideModalClose">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="guide-grid">
|
||||
<div class="guide-card">
|
||||
<h3>🧬 DEB 动态融合预测</h3>
|
||||
<p><b>Dynamic Ensemble Blending</b> 是系统的核心算法。它不只是简单的平均值,而是根据各个模型(ECMWF, GFS, MGM等)在**过去 30
|
||||
天**内的实际表现,动态分配权重。表现越稳的模型,在最终决策中占比越高。</p>
|
||||
<h3>🧮 DEB 动态融合预测</h3>
|
||||
<p><b>Dynamic Ensemble Blending</b> 是系统的核心预测层。它不是对 ECMWF、GFS、ICON、GEM、JMA 等模型的简单平均,而是结合最近样本表现、当前实况与城市偏置后得到的动态加权结果。</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h3>🎲 结算概率引擎 (μ)</h3>
|
||||
<p>基于正态分布模型。我们提取所有预报模型的离散度作为**不确定性系数 ($\sigma$)**,结合 DEB 预测值作为**期望值 ($\mu$)**。这能告诉你某个温度区间(如 10°C
|
||||
盘口)发生的真实概率,辅助对冲决策。</p>
|
||||
<h3>🎯 结算概率引擎</h3>
|
||||
<p>右侧的结算概率分布基于 DEB 预测值与多模型离散度动态计算。μ代表当前分布中心,会随着模型、实况和时间变化而变动,不是固定预报值。</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h3>📍 结算点 (Airport) 逻辑</h3>
|
||||
<p>Polymarket 结算是以**机场 METAR
|
||||
气象站**为准。在安卡拉等复杂城市,我们同时监控**市中心总站**(潜力热岛)和**机场站**(物理结算点)。当两者温差巨大时,往往预示着随后的补涨跳升机会。</p>
|
||||
<h3>✈️ 结算点与主观测源</h3>
|
||||
<p>Polymarket 结算逻辑以机场 METAR 为主。系统现在优先使用 Aviation Weather API 的机场报文与原始 METAR,并区分观测时间与接收时间,避免把发布延迟误认成温度变化。</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h3>⚠️ 风险偏置档案</h3>
|
||||
<p>系统记录了每个城市机场站与市区的距离、海拔差及季度性偏差。例如,首尔仁川机场因靠海,最高温通常比市区偏低;慕尼黑机场因海拔高,夜间降温极快。这些偏置已自动整合进 AI 分析逻辑中。
|
||||
</p>
|
||||
<h3>📍 Ankara 专属增强</h3>
|
||||
<p>Ankara 不走通用城市逻辑。结算主站以 LTAC / Esenboğa 为准,周边领先信号优先参考 Turkish MGM 站网,其中 <b>Ankara (Bölge/Center)</b> 是重点监控站,不由 Etimesgut 代替。</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h3>📈 未来日期概率分布</h3>
|
||||
<p>当你切换到未来 3-5 天时,系统会基于各模型对该日期的预报分歧度重新计算概率。分歧越大,分布越扁平(风险高);共识越强,分布越尖锐(机会明朗)。</p>
|
||||
<h3>📆 未来日期分析</h3>
|
||||
<p>点击多日预报后打开的模态框,主要用于分析下一个交易日。 6-48 小时趋势以 weather.gov 和 Open-Meteo 为主,部分城市可会补充 Meteoblue; 0-2 小时临近判断则优先看 METAR 与周边站。</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h3>📊 历史对账规则</h3>
|
||||
<p>历史准确率对账只统计已结算的样本。网页端采用近15天滚动视图,机器人 /deb 报告采用近7天窗口,不把当天未结算的样本算入胜率和 MAE。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="guide-footer">
|
||||
<p>※ 数据来源:NOAA METAR, Turkish MGM, Open-Meteo Ensemble, Meteoblue, NWS.</p>
|
||||
<p>※ 数据源以 Aviation Weather / METAR、Turkish MGM、Open-Meteo、weather.gov 为主,部分城市补充 Meteoblue。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── History Chart Modal ── -->
|
||||
<!-- 鈹€鈹€ History Chart Modal 鈹€鈹€ -->
|
||||
<div id="historyModal" class="modal-overlay hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>📊 <span id="historyModalTitle">历史准确率</span></h2>
|
||||
<button class="modal-close" id="historyModalClose">✕</button>
|
||||
<h2>📊 <span id="historyModalTitle">历史准确率对账</span></h2>
|
||||
<button class="modal-close" id="historyModalClose">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="history-stats" id="historyStats"></div>
|
||||
@@ -205,13 +181,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="futureForecastModal" class="modal-overlay hidden">
|
||||
<div class="modal-content large future-modal">
|
||||
<div class="modal-header">
|
||||
<h2>📆 <span id="futureForecastTitle">未来日期分析</span></h2>
|
||||
<button class="modal-close" id="futureForecastClose" aria-label="关闭未来日期分析">✕</button>
|
||||
</div>
|
||||
<div class="modal-body future-modal-body">
|
||||
<div class="history-stats" id="futureForecastStats"></div>
|
||||
|
||||
<section class="future-modal-section">
|
||||
<h3>📈 温度走势</h3>
|
||||
<div class="history-chart-wrapper future-chart-wrapper">
|
||||
<canvas id="futureForecastChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="future-modal-grid">
|
||||
<section class="future-modal-section">
|
||||
<h3>🎯 结算概率分布</h3>
|
||||
<div id="futureProbBars" class="prob-bars future-prob-bars"></div>
|
||||
</section>
|
||||
|
||||
<section class="future-modal-section">
|
||||
<h3>🧮 多模型预报</h3>
|
||||
<div id="futureModelBars" class="model-bars"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="future-modal-grid">
|
||||
<section class="future-modal-section">
|
||||
<h3><span class="section-inline-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V5"/><path d="M10 19V10"/><path d="M16 19V7"/><path d="M22 19V13"/></svg></span>未来 6-48 小时趋势</h3>
|
||||
<div id="futureTrendGrid" class="future-trend-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="future-modal-section">
|
||||
<h3 id="futureNowcastTitle">⏱ 目标日午后峰值窗口</h3>
|
||||
<div id="futureNowcast" class="future-text-block"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<!-- Leaflet Heatmap Plugin -->
|
||||
<script src="https://leaflet.github.io/Leaflet.heat/dist/leaflet-heat.js"></script>
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/static/app.js?v=legacy-v19"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 6.8 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 6.7 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -1148,9 +1148,11 @@ body {
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: calc(100vh - 48px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@@ -1180,6 +1182,7 @@ body {
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-stats {
|
||||
@@ -1242,6 +1245,173 @@ body {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.modal-content.large.future-modal {
|
||||
width: min(96vw, 1720px);
|
||||
max-width: 1720px;
|
||||
}
|
||||
|
||||
.future-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.future-modal-section {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.future-modal-section h3 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-inline-icon {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-cyan);
|
||||
flex: 0 0 16px;
|
||||
}
|
||||
|
||||
.section-inline-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.future-modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.future-chart-wrapper {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.future-prob-bars .prob-bar-fill {
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
.future-trend-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.future-trend-card {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.future-trend-label {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.future-trend-value {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.future-trend-note {
|
||||
margin-top: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.future-trend-value.warm {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.future-trend-value.cold {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.future-text-block {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.future-text-block strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.future-front-score {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.future-front-bar {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(245, 158, 11, 0.35), rgba(255,255,255,0.06) 50%, rgba(52, 211, 153, 0.35));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.future-front-bar::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: var(--score-position, 50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-cyan);
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.14);
|
||||
}
|
||||
|
||||
.future-front-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.future-front-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.future-modal-grid,
|
||||
.future-trend-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.future-trend-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.guide-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import csv
|
||||
import requests
|
||||
import re
|
||||
import time
|
||||
@@ -392,6 +393,9 @@ class WeatherDataCollector:
|
||||
"station_name": latest.get("name", icao),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"observation_time": obs_time,
|
||||
"report_time": latest.get("reportTime"),
|
||||
"receipt_time": latest.get("receiptTime"),
|
||||
"obs_time_epoch": latest.get("obsTime"),
|
||||
"current": {
|
||||
"temp": round(temp, 1) if temp is not None else None,
|
||||
"max_temp_so_far": round(max_so_far, 1)
|
||||
@@ -405,6 +409,7 @@ class WeatherDataCollector:
|
||||
"visibility_mi": latest.get("visib"),
|
||||
"wx_desc": latest.get("wxString"),
|
||||
"altimeter": latest.get("altim"),
|
||||
"raw_metar": latest.get("rawOb"),
|
||||
"clouds": latest.get("clouds", []),
|
||||
},
|
||||
"recent_temps": recent_temps, # 最近4条: [("15:00", 5), ("14:20", 5), ...]
|
||||
@@ -754,7 +759,12 @@ class WeatherDataCollector:
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"temp": round(display_temp, 1),
|
||||
"istNo": icao # 用 ICAO ID 作为标识
|
||||
"istNo": icao, # 用 ICAO ID 作为标识
|
||||
"icao": icao,
|
||||
"wind_dir": obs.get("wdir"),
|
||||
"wind_speed": obs.get("wspd"),
|
||||
"wind_speed_kt": obs.get("wspd"),
|
||||
"raw_metar": obs.get("rawOb"),
|
||||
})
|
||||
|
||||
if results:
|
||||
@@ -780,7 +790,9 @@ class WeatherDataCollector:
|
||||
points_resp.raise_for_status()
|
||||
points_data = points_resp.json()
|
||||
|
||||
forecast_url = points_data.get("properties", {}).get("forecast")
|
||||
properties = points_data.get("properties", {})
|
||||
forecast_url = properties.get("forecast")
|
||||
hourly_url = properties.get("forecastHourly")
|
||||
if not forecast_url:
|
||||
return None
|
||||
|
||||
@@ -795,6 +807,41 @@ class WeatherDataCollector:
|
||||
if not periods:
|
||||
return None
|
||||
|
||||
hourly_periods = []
|
||||
if hourly_url:
|
||||
hourly_resp = self.session.get(
|
||||
hourly_url, headers=headers, timeout=self.timeout
|
||||
)
|
||||
hourly_resp.raise_for_status()
|
||||
hourly_data = hourly_resp.json()
|
||||
hourly_periods = hourly_data.get("properties", {}).get("periods", [])[:48]
|
||||
|
||||
active_alerts = []
|
||||
try:
|
||||
alerts_resp = self.session.get(
|
||||
"https://api.weather.gov/alerts/active",
|
||||
params={"point": f"{lat},{lon}"},
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
alerts_resp.raise_for_status()
|
||||
alerts_data = alerts_resp.json()
|
||||
for feature in alerts_data.get("features", [])[:8]:
|
||||
ap = feature.get("properties", {})
|
||||
active_alerts.append(
|
||||
{
|
||||
"event": ap.get("event"),
|
||||
"headline": ap.get("headline"),
|
||||
"severity": ap.get("severity"),
|
||||
"certainty": ap.get("certainty"),
|
||||
"urgency": ap.get("urgency"),
|
||||
"effective": ap.get("effective"),
|
||||
"ends": ap.get("ends"),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
active_alerts = []
|
||||
|
||||
# 3. 提取今日最高温(找 isDaytime=True 的第一个)
|
||||
today_high = None
|
||||
for p in periods:
|
||||
@@ -812,6 +859,36 @@ class WeatherDataCollector:
|
||||
"source": "nws",
|
||||
"today_high": today_high,
|
||||
"unit": "fahrenheit",
|
||||
"forecast_periods": [
|
||||
{
|
||||
"name": p.get("name"),
|
||||
"start_time": p.get("startTime"),
|
||||
"end_time": p.get("endTime"),
|
||||
"is_daytime": p.get("isDaytime"),
|
||||
"temperature": p.get("temperature"),
|
||||
"temperature_trend": p.get("temperatureTrend"),
|
||||
"wind_speed": p.get("windSpeed"),
|
||||
"wind_direction": p.get("windDirection"),
|
||||
"short_forecast": p.get("shortForecast"),
|
||||
"detailed_forecast": p.get("detailedForecast"),
|
||||
"precipitation_probability": (p.get("probabilityOfPrecipitation") or {}).get("value"),
|
||||
}
|
||||
for p in periods[:14]
|
||||
],
|
||||
"hourly_periods": [
|
||||
{
|
||||
"start_time": p.get("startTime"),
|
||||
"end_time": p.get("endTime"),
|
||||
"temperature": p.get("temperature"),
|
||||
"temperature_unit": p.get("temperatureUnit"),
|
||||
"wind_speed": p.get("windSpeed"),
|
||||
"wind_direction": p.get("windDirection"),
|
||||
"short_forecast": p.get("shortForecast"),
|
||||
"precipitation_probability": (p.get("probabilityOfPrecipitation") or {}).get("value"),
|
||||
}
|
||||
for p in hourly_periods
|
||||
],
|
||||
"active_alerts": active_alerts,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"NWS 请求失败: {e}")
|
||||
@@ -839,7 +916,7 @@ class WeatherDataCollector:
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"current_weather": "true",
|
||||
"hourly": "temperature_2m,shortwave_radiation",
|
||||
"hourly": "temperature_2m,shortwave_radiation,dew_point_2m,pressure_msl,wind_speed_10m,wind_direction_10m,precipitation_probability,cloud_cover",
|
||||
"daily": "temperature_2m_max,apparent_temperature_max,sunrise,sunset,sunshine_duration",
|
||||
"timezone": "auto",
|
||||
"forecast_days": forecast_days,
|
||||
@@ -1426,16 +1503,14 @@ class WeatherDataCollector:
|
||||
# 获取时区偏移以过滤 METAR
|
||||
utc_offset = open_meteo.get("utc_offset", 0)
|
||||
|
||||
# 对伦敦,获取 Meteoblue 预测 (公认最准)
|
||||
if city_lower == "london":
|
||||
mb_data = self.fetch_from_meteoblue(
|
||||
lat,
|
||||
lon,
|
||||
timezone_name=open_meteo.get("timezone", "UTC"),
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
)
|
||||
if mb_data:
|
||||
results["meteoblue"] = mb_data
|
||||
mb_data = self.fetch_from_meteoblue(
|
||||
lat,
|
||||
lon,
|
||||
timezone_name=open_meteo.get("timezone", "UTC"),
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
)
|
||||
if mb_data:
|
||||
results["meteoblue"] = mb_data
|
||||
|
||||
# 对美国城市,额外获取 NWS 高精预报
|
||||
if use_fahrenheit:
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
const fs = require('fs');
|
||||
|
||||
function replaceRegex(content, regex, replacement, label) {
|
||||
const next = content.replace(regex, replacement);
|
||||
if (next === content) {
|
||||
console.error('No match for', label);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
const appJsPath = 'E:/web/PolyWeather/frontend/public/static/app.js';
|
||||
let app = fs.readFileSync(appJsPath, 'utf8');
|
||||
|
||||
app = replaceRegex(app, /function updateCityListInfo\(cityData\) \{[\s\S]*?\n\}/, `function updateCityListInfo(cityData) {
|
||||
const cityName = cityData.name;
|
||||
const cityId = cityName.replace(/\\s/g, "-");
|
||||
const temp = cityData.current?.temp;
|
||||
|
||||
const tempEl = document.getElementById(\`temp-\${cityId}\`);
|
||||
if (tempEl && temp != null) {
|
||||
tempEl.textContent = \`\${temp}\${cityData.temp_symbol}\`;
|
||||
tempEl.classList.add("loaded");
|
||||
}
|
||||
|
||||
const timeEl = document.getElementById(\`time-\${cityId}\`);
|
||||
if (timeEl && cityData.local_time) {
|
||||
timeEl.textContent = \`🕐 \${cityData.local_time}\`;
|
||||
}
|
||||
|
||||
const maxEl = document.getElementById(\`max-\${cityId}\`);
|
||||
if (maxEl && cityData.current?.max_temp_time) {
|
||||
maxEl.textContent = \`峰值 @\${cityData.current.max_temp_time}\`;
|
||||
}
|
||||
}`,'updateCityListInfo');
|
||||
|
||||
app = app.replace(/alert\(`[^`]*cityName[^`]*`\);/, 'alert(`加载 ${cityName} 数据失败:${e.message}`);');
|
||||
|
||||
app = replaceRegex(app, /function renderPanel\(data\) \{[\s\S]*?\n\}/, `function renderPanel(data) {
|
||||
const panel = document.getElementById("panel");
|
||||
if (!panel) return;
|
||||
panel.classList.remove("hidden");
|
||||
requestAnimationFrame(() => panel.classList.add("visible"));
|
||||
|
||||
const panelCityName = document.getElementById("panelCityName");
|
||||
const panelLocalTime = document.getElementById("panelLocalTime");
|
||||
const badge = document.getElementById("panelRiskBadge");
|
||||
|
||||
if (panelCityName) {
|
||||
panelCityName.textContent = \`\${data.risk?.emoji || "🏙️"} \${data.display_name}\`;
|
||||
}
|
||||
if (panelLocalTime) {
|
||||
panelLocalTime.textContent = \`🕐 \${data.local_time || "--:--"} 当地时间\`;
|
||||
}
|
||||
if (badge) {
|
||||
badge.textContent = {
|
||||
high: "🔴 高危",
|
||||
medium: "🟡 中危",
|
||||
low: "🟢 低危",
|
||||
}[data.risk?.level] || "未知";
|
||||
badge.className = \`risk-badge \${data.risk?.level || "low"}\`;
|
||||
}
|
||||
|
||||
renderHero(data);
|
||||
renderChart(data);
|
||||
renderProbabilities(data);
|
||||
if (!selectedForecastDate) {
|
||||
selectedForecastDate = data.local_date;
|
||||
}
|
||||
renderModels(data);
|
||||
renderForecast(data);
|
||||
renderAI(data);
|
||||
renderRisk(data);
|
||||
}`,'renderPanel');
|
||||
|
||||
app = replaceRegex(app, /const METAR_WX_MAP = \{[\s\S]*?\n\};/, `const METAR_WX_MAP = {
|
||||
RA: { label: "降雨", icon: "🌧️" },
|
||||
"-RA": { label: "小雨", icon: "🌦️" },
|
||||
"+RA": { label: "强降雨", icon: "⛈️" },
|
||||
SN: { label: "降雪", icon: "❄️" },
|
||||
"-SN": { label: "小雪", icon: "🌨️" },
|
||||
"+SN": { label: "大雪", icon: "🌨️" },
|
||||
DZ: { label: "毛毛雨", icon: "🌦️" },
|
||||
FG: { label: "雾", icon: "🌫️" },
|
||||
BR: { label: "薄雾", icon: "🌫️" },
|
||||
HZ: { label: "霾", icon: "🌫️" },
|
||||
TS: { label: "雷暴", icon: "⛈️" },
|
||||
VCTS: { label: "附近雷暴", icon: "⛈️" },
|
||||
SQ: { label: "飑", icon: "💨" },
|
||||
GS: { label: "冰雹", icon: "🌨️" },
|
||||
};`, 'METAR_WX_MAP');
|
||||
|
||||
app = app.replace(/return \{ label: code, icon: .*? \};/, 'return { label: code, icon: "🌤️" };');
|
||||
|
||||
app = replaceRegex(app, /function renderHero\(data\) \{[\s\S]*?document.getElementById\("heroWeather"\)\.innerHTML = `/, `function renderHero(data) {
|
||||
const cur = data.current || {};
|
||||
const sym = data.temp_symbol || "°C";
|
||||
const displayTemp = cur.temp;
|
||||
|
||||
let weatherText = cur.cloud_desc || "未知";
|
||||
let weatherIcon =
|
||||
{
|
||||
多云: "☁️",
|
||||
阴天: "☁️",
|
||||
少云: "🌤️",
|
||||
散云: "☁️",
|
||||
晴: "☀️",
|
||||
晴朗: "☀️",
|
||||
}[cur.cloud_desc] || "🌤️";
|
||||
|
||||
if (cur.wx_desc) {
|
||||
const metarTranslation = translateMETAR(cur.wx_desc);
|
||||
if (metarTranslation) {
|
||||
weatherText = metarTranslation.label;
|
||||
weatherIcon = metarTranslation.icon;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("heroWeather").innerHTML = ``, 'renderHeroPrefix');
|
||||
|
||||
fs.writeFileSync(appJsPath, app, 'utf8');
|
||||
|
||||
const pagePath = 'E:/web/PolyWeather/frontend/app/page.tsx';
|
||||
let page = fs.readFileSync(pagePath, 'utf8');
|
||||
page = page.replace(/legacy-v13/g, 'legacy-v14');
|
||||
fs.writeFileSync(pagePath, page, 'utf8');
|
||||
|
||||
const htmlPath = 'E:/web/PolyWeather/frontend/public/legacy/index.html';
|
||||
let html = fs.readFileSync(htmlPath, 'utf8');
|
||||
html = html.replace(/legacy-v13/g, 'legacy-v14');
|
||||
fs.writeFileSync(htmlPath, html, 'utf8');
|
||||
@@ -262,6 +262,12 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
h_times = hourly.get("time", [])
|
||||
h_temps = hourly.get("temperature_2m", [])
|
||||
h_rad = hourly.get("shortwave_radiation", [])
|
||||
h_dew = hourly.get("dew_point_2m", [])
|
||||
h_pressure = hourly.get("pressure_msl", [])
|
||||
h_wspd = hourly.get("wind_speed_10m", [])
|
||||
h_wdir = hourly.get("wind_direction_10m", [])
|
||||
h_precip_prob = hourly.get("precipitation_probability", [])
|
||||
h_cloud_cover = hourly.get("cloud_cover", [])
|
||||
|
||||
peak_hours = []
|
||||
if h_times and h_temps and om_today is not None:
|
||||
@@ -326,6 +332,54 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
today_hourly["temps"].append(h_temps[i] if i < len(h_temps) else None)
|
||||
today_hourly["radiation"].append(h_rad[i] if i < len(h_rad) else None)
|
||||
|
||||
# ── 12b. Next 48h hourly block for future-date analysis modal ──
|
||||
next_48h_hourly = {
|
||||
"times": [],
|
||||
"temps": [],
|
||||
"radiation": [],
|
||||
"dew_point": [],
|
||||
"pressure_msl": [],
|
||||
"wind_speed_10m": [],
|
||||
"wind_direction_10m": [],
|
||||
"precipitation_probability": [],
|
||||
"cloud_cover": [],
|
||||
}
|
||||
try:
|
||||
local_anchor = datetime.strptime(
|
||||
f"{local_date_str} {local_time_str}", "%Y-%m-%d %H:%M"
|
||||
)
|
||||
except Exception:
|
||||
local_anchor = None
|
||||
|
||||
if local_anchor is not None:
|
||||
horizon = local_anchor + timedelta(hours=48)
|
||||
for i, ts in enumerate(h_times):
|
||||
try:
|
||||
ts_dt = datetime.fromisoformat(ts)
|
||||
except Exception:
|
||||
continue
|
||||
if ts_dt < local_anchor or ts_dt > horizon:
|
||||
continue
|
||||
next_48h_hourly["times"].append(ts)
|
||||
next_48h_hourly["temps"].append(h_temps[i] if i < len(h_temps) else None)
|
||||
next_48h_hourly["radiation"].append(h_rad[i] if i < len(h_rad) else None)
|
||||
next_48h_hourly["dew_point"].append(h_dew[i] if i < len(h_dew) else None)
|
||||
next_48h_hourly["pressure_msl"].append(
|
||||
h_pressure[i] if i < len(h_pressure) else None
|
||||
)
|
||||
next_48h_hourly["wind_speed_10m"].append(
|
||||
h_wspd[i] if i < len(h_wspd) else None
|
||||
)
|
||||
next_48h_hourly["wind_direction_10m"].append(
|
||||
h_wdir[i] if i < len(h_wdir) else None
|
||||
)
|
||||
next_48h_hourly["precipitation_probability"].append(
|
||||
h_precip_prob[i] if i < len(h_precip_prob) else None
|
||||
)
|
||||
next_48h_hourly["cloud_cover"].append(
|
||||
h_cloud_cover[i] if i < len(h_cloud_cover) else None
|
||||
)
|
||||
|
||||
# ── 13. Cloud description (METAR primary, MGM fallback) ──
|
||||
clouds = mc.get("clouds", [])
|
||||
cloud_desc = ""
|
||||
@@ -489,6 +543,9 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
"wu_settlement": wu_settle,
|
||||
"obs_time": obs_time_str,
|
||||
"obs_age_min": metar_age_min,
|
||||
"report_time": metar.get("report_time") if metar else None,
|
||||
"receipt_time": metar.get("receipt_time") if metar else None,
|
||||
"obs_time_epoch": metar.get("obs_time_epoch") if metar else None,
|
||||
"wind_speed_kt": _sf(mc.get("wind_speed_kt")),
|
||||
"wind_dir": _sf(mc.get("wind_dir")),
|
||||
"humidity": _sf(mc.get("humidity")),
|
||||
@@ -498,6 +555,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
],
|
||||
"visibility_mi": _sf(mc.get("visibility_mi")),
|
||||
"wx_desc": mc.get("wx_desc"),
|
||||
"raw_metar": mc.get("raw_metar"),
|
||||
},
|
||||
"mgm": mgm_data,
|
||||
"mgm_nearby": raw.get("mgm_nearby", []),
|
||||
@@ -508,6 +566,10 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
"sunset": sunset,
|
||||
"sunshine_hours": sunshine_h,
|
||||
},
|
||||
"source_forecasts": {
|
||||
"weather_gov": raw.get("nws") or {},
|
||||
"meteoblue": raw.get("meteoblue") or {},
|
||||
},
|
||||
"multi_model": {k: v for k, v in current_forecasts.items() if v is not None},
|
||||
"multi_model_daily": multi_model_daily,
|
||||
"deb": {"prediction": deb_val, "weights_info": deb_weights},
|
||||
@@ -524,6 +586,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
"status": peak_status,
|
||||
},
|
||||
"hourly": today_hourly,
|
||||
"hourly_next_48h": next_48h_hourly,
|
||||
"metar_today_obs": [
|
||||
{"time": t, "temp": v}
|
||||
for t, v in (metar.get("today_obs", []) if metar else [])
|
||||
@@ -587,6 +650,110 @@ def _normalize_city_or_404(name: str) -> str:
|
||||
return city
|
||||
|
||||
|
||||
def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": data.get("name"),
|
||||
"display_name": data.get("display_name"),
|
||||
"icao": data.get("risk", {}).get("icao"),
|
||||
"local_time": data.get("local_time"),
|
||||
"temp_symbol": data.get("temp_symbol"),
|
||||
"current": {
|
||||
"temp": data.get("current", {}).get("temp"),
|
||||
"obs_time": data.get("current", {}).get("obs_time"),
|
||||
},
|
||||
"deb": {
|
||||
"prediction": data.get("deb", {}).get("prediction"),
|
||||
},
|
||||
"risk": {
|
||||
"level": data.get("risk", {}).get("level"),
|
||||
"warning": data.get("risk", {}).get("warning"),
|
||||
},
|
||||
"updated_at": data.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def _build_city_detail_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
distribution = data.get("probabilities", {}).get("distribution", []) or []
|
||||
primary_bucket = distribution[0] if distribution else None
|
||||
return {
|
||||
"city": data.get("name"),
|
||||
"fetched_at": data.get("updated_at"),
|
||||
"overview": {
|
||||
"name": data.get("name"),
|
||||
"display_name": data.get("display_name"),
|
||||
"icao": data.get("risk", {}).get("icao"),
|
||||
"airport": data.get("risk", {}).get("airport"),
|
||||
"lat": data.get("lat"),
|
||||
"lon": data.get("lon"),
|
||||
"local_time": data.get("local_time"),
|
||||
"local_date": data.get("local_date"),
|
||||
"temp_symbol": data.get("temp_symbol"),
|
||||
"current_temp": data.get("current", {}).get("temp"),
|
||||
"deb_prediction": data.get("deb", {}).get("prediction"),
|
||||
"risk_level": data.get("risk", {}).get("level"),
|
||||
"risk_warning": data.get("risk", {}).get("warning"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
},
|
||||
"official": {
|
||||
"available": bool(data.get("current", {}).get("temp") is not None),
|
||||
"metar": {
|
||||
"observation_time": data.get("current", {}).get("obs_time"),
|
||||
"obs_age_min": data.get("current", {}).get("obs_age_min"),
|
||||
"report_time": data.get("current", {}).get("report_time"),
|
||||
"receipt_time": data.get("current", {}).get("receipt_time"),
|
||||
"raw_metar": data.get("current", {}).get("raw_metar"),
|
||||
"current": data.get("current"),
|
||||
},
|
||||
"weather_gov": {},
|
||||
"mgm": data.get("mgm") or {},
|
||||
"mgm_nearby": data.get("mgm_nearby") or [],
|
||||
"nearby_source": "mgm" if data.get("name") == "ankara" else "metar_cluster",
|
||||
},
|
||||
"timeseries": {
|
||||
"metar_recent_obs": data.get("metar_recent_obs") or [],
|
||||
"metar_today_obs": data.get("metar_today_obs") or [],
|
||||
"hourly": data.get("hourly") or {},
|
||||
"mgm_hourly": (data.get("mgm") or {}).get("hourly", []),
|
||||
"forecast_daily": (data.get("forecast") or {}).get("daily", []),
|
||||
},
|
||||
"models": data.get("multi_model") or {},
|
||||
"probabilities": data.get("probabilities") or {"mu": None, "distribution": []},
|
||||
"market_scan": {
|
||||
"available": False,
|
||||
"reason": "Market layer is not available on the current backend build.",
|
||||
"primary_market": None,
|
||||
"selected_date": data.get("local_date"),
|
||||
"selected_condition_id": None,
|
||||
"selected_slug": None,
|
||||
"temperature_bucket": primary_bucket,
|
||||
"model_probability": (
|
||||
(primary_bucket.get("probability") / 100.0)
|
||||
if isinstance(primary_bucket, dict) and primary_bucket.get("probability") is not None
|
||||
else None
|
||||
),
|
||||
"market_price": None,
|
||||
"edge_percent": None,
|
||||
"signal_label": "MONITOR",
|
||||
"confidence": "low",
|
||||
"yes_token": None,
|
||||
"no_token": None,
|
||||
"yes_buy": None,
|
||||
"yes_sell": None,
|
||||
"no_buy": None,
|
||||
"no_sell": None,
|
||||
"last_trade_price": None,
|
||||
"liquidity": None,
|
||||
"volume": None,
|
||||
"sparkline": [p.get("probability", 0) for p in distribution[:8] if isinstance(p, dict)],
|
||||
"recent_trades": [],
|
||||
"websocket": {},
|
||||
},
|
||||
"risk": data.get("risk"),
|
||||
"ai_analysis": data.get("ai_analysis") or "",
|
||||
"errors": {},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/history/{name}")
|
||||
async def city_history(name: str):
|
||||
"""Return historical accuracy data (DEB, mu, actuals) for a city."""
|
||||
@@ -621,6 +788,20 @@ async def city_history(name: str):
|
||||
})
|
||||
return {"history": out}
|
||||
|
||||
|
||||
@app.get("/api/city/{name}/summary")
|
||||
async def city_summary(name: str, force_refresh: bool = False):
|
||||
city = _normalize_city_or_404(name)
|
||||
data = _analyze(city, force_refresh=force_refresh)
|
||||
return _build_city_summary_payload(data)
|
||||
|
||||
|
||||
@app.get("/api/city/{name}/detail")
|
||||
async def city_detail_aggregate(name: str, force_refresh: bool = False):
|
||||
city = _normalize_city_or_404(name)
|
||||
data = _analyze(city, force_refresh=force_refresh)
|
||||
return _build_city_detail_payload(data)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Entrypoint
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||