diff --git a/README.md b/README.md index 08244429..89d55d59 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,13 @@ This project uses a decoupled production setup for reliability and iteration spe - **City Specialization**: `17130` (`Ankara (Bölge/Center)`) remains the Ankara lead station without replacing LTAC settlement observation. - **⚖️ DEB Smart Blending** - Dynamic weighting based on city-level performance and current model spread. -- **🧩 React Dashboard Runtime** +- **📈 Market Data Integration** + - Live Polymarket quotes, probabilities, and dynamic settlement bucket tracking. + - Automatic Market Edge and Spread calculation comparing DEB vs Market. +- **🧩 React Dashboard Runtime (v1.2)** + - Fully internationalized (i18n) with English and Simplified Chinese support. + - Premium UI: Glassmorphism overlays, dynamic "Sonar Pulse" markers, and fluid loading states. - 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 short-window temperature slope changes. @@ -140,12 +144,12 @@ Set `frontend` as the Vercel root directory for automatic CI/CD. ## 💬 Bot Commands -| Command | Description | Example | -| :-------- | :-------------------------------------- | :------------- | -| `/city` | Query real-time analysis for a city | `/city ankara` | -| `/deb` | View historical accuracy of DEB model | `/deb london` | -| `/top` | View activity leaderboard | `/top` | -| `/help` | Get detailed instructions | `/help` | +| Command | Description | Example | +| :------ | :------------------------------------ | :------------- | +| `/city` | Query real-time analysis for a city | `/city ankara` | +| `/deb` | View historical accuracy of DEB model | `/deb london` | +| `/top` | View activity leaderboard | `/top` | +| `/help` | Get detailed instructions | `/help` | --- @@ -161,8 +165,8 @@ Set `frontend` as the Vercel root directory for automatic CI/CD. --- -**📅 Last Updated**: 2026-03-09 -**🚀 Status**: v1.1 Stable - React Dashboard Runtime in Production +**📅 Last Updated**: 2026-03-10 +**🚀 Status**: v1.2 Stable - React Dashboard with i18n & Polymarket Integration in Production > [!TIP] -> **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. +> **Production Note**: The UI layout remains consistent while introducing full internationalization, market quote integration, and premium visual feedback (glassmorphism overlays, sonar markers). diff --git a/README_ZH.md b/README_ZH.md index 2ee7e92c..6604974c 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -46,9 +46,13 @@ PolyWeather 是一套专为 **Polymarket** 深度博弈者设计的实时情报 - **城市特化**:安卡拉保留 `17130`(`Ankara (Bölge/Center)`)领先站逻辑,不替代 LTAC 结算主站。 - **⚖️ DEB 智能融合** - 基于城市历史表现与当前模型分歧动态调整权重。 -- **🧩 React 仪表盘运行时** +- **📈 市场数据深度整合** + - 实时接入 Polymarket 报价、结算概率及动态“最热温度桶”追踪。 + - 自动对比 DEB 与市场差值,计算 Edge 与点差。 +- **🧩 React 仪表盘运行时 (v1.2)** + - 全面支持双语国际化 (i18n):英文与简体中文无缝切换。 + - 尊享版 UI:新增毛玻璃效果、动态 Sonar 脉冲标记及流畅的全屏加载动效。 - 类型化 Store + 类型化 Data Client + Leaflet/Chart.js 生命周期封装。 - - 点击城市:地图聚焦 + 右侧卡片打开 + 周边站点展示。 - 点击“今日日内分析”:打开模态框并冻结地图动画。 - **🔔 异动预警系统** - **动量突变**:捕捉短窗口温度斜率变化。 @@ -140,12 +144,12 @@ docker-compose up -d --build ## 💬 机器人指令 -| 命令 | 说明 | 示例 | -| :-------- | :------------------------ | :------------- | -| `/city` | 查询指定城市实时分析 | `/city ankara` | -| `/deb` | 查看 DEB 模型的历史准确率 | `/deb london` | -| `/top` | 查看活跃积分排行榜 | `/top` | -| `/help` | 获取详细功能说明 | `/help` | +| 命令 | 说明 | 示例 | +| :------ | :------------------------ | :------------- | +| `/city` | 查询指定城市实时分析 | `/city ankara` | +| `/deb` | 查看 DEB 模型的历史准确率 | `/deb london` | +| `/top` | 查看活跃积分排行榜 | `/top` | +| `/help` | 获取详细功能说明 | `/help` | --- @@ -161,8 +165,8 @@ docker-compose up -d --build --- -**📅 最后更新**:2026-03-09 -**🚀 状态**:v1.1 稳定版 - React 运行时已上线 +**📅 最后更新**:2026-03-10 +**🚀 状态**:v1.2 稳定版 - 国际化及 Polymarket 市场层融合已上线 > [!TIP] -> **生产提示**:在不改变既有 UI 布局与视觉层级的前提下,数据流、地图联动和模态行为已迁移到类型化 React 组件体系。 +> **生产提示**:在不改变既有 UI 布局的前提下,前端已全面引入国际化、市场报价集成及进阶视觉效果(如动态雷达标记、高级加载与毛玻璃控件)。 diff --git a/docs/API_ZH.md b/docs/API_ZH.md index 281518eb..a35e24f6 100644 --- a/docs/API_ZH.md +++ b/docs/API_ZH.md @@ -160,9 +160,10 @@ - `primary_market` - `selected_condition_id` - `yes_token` / `no_token` - - `yes_buy` / `yes_sell` / `no_buy` / `no_sell` + - `yes_buy` / `yes_sell` / `no_buy` / `no_sell` / `model_probability` - `market_price`(优先 midpoint) - `edge_percent`(`(model_probability - market_price) * 100`) + - `temperature_bucket` / `top_buckets` (结算温度桶及市场概率) - `signal_label`(`BUY YES` / `BUY NO` / `MONITOR`) - `websocket.asset_ids` / `websocket.condition_ids`(仅用于订阅标识,P0 不下单) @@ -249,4 +250,4 @@ --- -**最后更新**: 2026-03-09 +**最后更新**: 2026-03-10 diff --git a/docs/COMMERCIALIZATION.md b/docs/COMMERCIALIZATION.md index 1f33c329..768a4e7f 100644 --- a/docs/COMMERCIALIZATION.md +++ b/docs/COMMERCIALIZATION.md @@ -12,11 +12,11 @@ PolyWeather is positioned as a **premium intelligence service** for weather-driv ## 💰 Pricing & Monetization -| Tier | Price | Primary Value Proposition | -| :------------------- | :------------ | :------------------------------------------------------------ | -| **Telegram Channel** | **$1 / mo** | High-fidelity proactive alerts, low noise. | -| **Web Dashboard** | **$5 / mo** | Full multi-model context + historical DEB benchmarking. | -| **VIP Bundle** | **$5.5 / mo** | Unified access to dashboard + signal stream. | +| Tier | Price | Primary Value Proposition | +| :------------------- | :------------ | :------------------------------------------------------ | +| **Telegram Channel** | **$1 / mo** | High-fidelity proactive alerts, low noise. | +| **Web Dashboard** | **$5 / mo** | Full multi-model context + historical DEB benchmarking. | +| **VIP Bundle** | **$5.5 / mo** | Unified access to dashboard + signal stream. | ### 🛠️ Payment Infrastructure @@ -92,4 +92,4 @@ graph LR --- -**📅 Last Updated**: 2026-03-09 +**📅 Last Updated**: 2026-03-10 diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md index 566c6888..8286681e 100644 --- a/docs/TECH_DEBT.md +++ b/docs/TECH_DEBT.md @@ -22,7 +22,8 @@ The core weather engine and React dashboard runtime are now stable, but product- - [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) +- [x] React component-driven dashboard runtime +- [x] Internationalization (i18n) & Polymarket P0 Data Merge --- @@ -33,18 +34,18 @@ The core weather engine and React dashboard runtime are now stable, but product- | **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. | +| **Entitlement Guard** | Dashboard routes are public by default. | Add JWT/session gating in Next.js middleware + backend checks. | --- ## 🟡 Medium Priority: Quality of Life -| Debt Item | Impact | Suggested Fix | -| :------------------------ | :-------------------------------------------------- | :--------------------------------------------------------------------------- | -| **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. | +| Debt Item | Impact | Suggested Fix | +| :------------------------- | :-------------------------------------------------- | :--------------------------------------------------------------------------- | +| **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. | --- @@ -66,4 +67,4 @@ The core weather engine and React dashboard runtime are now stable, but product- --- -**📅 Last Updated**: 2026-03-09 +**📅 Last Updated**: 2026-03-10 diff --git a/docs/TECH_DEBT_ZH.md b/docs/TECH_DEBT_ZH.md index d8281554..d3d43e15 100644 --- a/docs/TECH_DEBT_ZH.md +++ b/docs/TECH_DEBT_ZH.md @@ -22,38 +22,39 @@ pie title 系统健康度与技术债 - [x] DEB 融合算法 - [x] 主动式 Telegram 预警引擎 - [x] Vercel 仪表盘基础设施 -- [x] React 组件驱动仪表盘运行时(已替换 legacy `public/static/app.js` 渲染路径) +- [x] React 组件驱动仪表盘运行时 +- [x] 国际化 (i18n) 与前端市场数据集成 (Polymarket) --- ## 🔴 高优先级:立即处理 -| 债务项 | 影响 | 建议修复 | -| :-------------------- | :------------------------------------------------- | :--------------------------------------------------------------- | -| **Monolithic Bot** | `bot_listener.py` 可测试性差,演进成本高。 | 将 UI 交互与业务逻辑解耦,沉入 `src/analysis`。 | -| **Subscription Store**| 付费用户缺少持久化记录。 | 从内存校验迁移到 **Supabase/PostgreSQL**。 | -| **Alert Transparency**| 运维侧难以审计“告警为何触发”。 | 为所有内部告警载荷增加 `Evidence` 元数据块。 | -| **Entitlement Guard** | 仪表盘路由默认仍是公开可访问。 | 在 Next.js middleware 与后端校验中加入 JWT/会话权限守卫。 | +| 债务项 | 影响 | 建议修复 | +| :--------------------- | :----------------------------------------- | :-------------------------------------------------------- | +| **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 生命周期,回归风险高。 | 增加图表数据集与图例的快照测试 + 交互测试。 | +| 债务项 | 影响 | 建议修复 | +| :------------------------- | :--------------------------------------------- | :--------------------------------------------------- | +| **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)。 | +| 债务项 | 影响 | 建议修复 | +| :------------------------- | :----------------------------- | :--------------------------------------- | +| **Serverless Cold Starts** | Vercel 首次 API 调用可能偏慢。 | 为主要城市接口增加边缘缓存或预热任务。 | +| **Local SQLite Files** | 与 Vercel 短暂文件系统不兼容。 | 全面迁移到远程数据库(Supabase/Redis)。 | --- @@ -66,4 +67,4 @@ pie title 系统健康度与技术债 --- -**📅 最后更新**:2026-03-09 +**📅 最后更新**:2026-03-10 diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index f745086c..2454336e 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -33,7 +33,7 @@ /* Spacing */ --panel-width: 560px; --header-height: 56px; - --sidebar-width: 280px; + --sidebar-width: 260px; /* Effects */ --glass-blur: 20px; @@ -328,6 +328,7 @@ } .root :global(.city-item) { + width: 100%; display: flex; flex-direction: column; gap: 4px; @@ -336,6 +337,10 @@ cursor: pointer; transition: var(--transition); border: 1px solid transparent; + background: transparent; + color: inherit; + font-family: inherit; + text-align: left; } .root :global(.city-item:hover) { background: rgba(99, 102, 241, 0.08); @@ -722,6 +727,29 @@ background: rgba(99, 102, 241, 0.15); } +.root :global(.prob-market-inline) { + min-width: 120px; + text-align: right; + font-size: 12px; + font-weight: 700; + border-radius: 999px; + padding: 4px 10px; + letter-spacing: 0.02em; + font-variant-numeric: tabular-nums; +} + +.root :global(.prob-market-inline.yes) { + color: #4ade80; + background: rgba(74, 222, 128, 0.12); + border: 1px solid rgba(74, 222, 128, 0.3); +} + +.root :global(.prob-market-inline.no) { + color: #fb7185; + background: rgba(251, 113, 133, 0.12); + border: 1px solid rgba(251, 113, 133, 0.26); +} + /* ── Model Bars ── */ .root :global(.model-bars) { display: flex; @@ -1386,6 +1414,41 @@ color: var(--text-primary); margin: 0; } +.root :global(.future-modal-title-with-actions) { + display: flex; + align-items: center; + gap: 12px; +} + +.root :global(.future-refresh-btn) { + background: transparent; + border: none; + cursor: pointer; + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + border-radius: 6px; + transition: all 0.2s ease; +} + +.root :global(.future-refresh-btn:hover) { + color: var(--accent-cyan); + background: rgba(34, 211, 238, 0.1); +} + +.root :global(.future-refresh-btn.spinning svg) { + animation: spin 1s linear infinite; + color: var(--accent-cyan); +} + +@keyframes spin { + 100% { + transform: rotate(360deg); + } +} + .root :global(.modal-close) { background: none; border: none; @@ -1435,6 +1498,14 @@ color: var(--text-primary); } +.root :global(.h-stat-card .h-stat-note) { + display: block; + margin-top: 6px; + font-size: 11px; + color: var(--text-muted); + line-height: 1.35; +} + .root :global(.history-chart-wrapper) { position: relative; height: 300px; @@ -1551,6 +1622,301 @@ padding-right: 14px; } +.root :global(.future-v2-layout) { + display: grid; + grid-template-columns: 360px minmax(0, 1fr); + gap: 14px; + align-items: start; +} + +.root :global(.future-v2-left) { + display: grid; + gap: 12px; +} + +.root :global(.future-v2-right) { + display: grid; + gap: 14px; +} + +.root :global(.future-v2-card) { + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border-subtle); + border-radius: 14px; + padding: 14px; +} + +.root :global(.future-v2-hero-card) { + background: linear-gradient( + 180deg, + rgba(99, 102, 241, 0.12) 0%, + rgba(255, 255, 255, 0.02) 100% + ); +} + +.root :global(.future-v2-hero-title) { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.01em; +} + +.root :global(.future-v2-hero-main) { + display: flex; + align-items: center; + gap: 14px; + margin-top: 14px; +} + +.root :global(.future-v2-hero-temp) { + font-size: 56px; + font-weight: 800; + letter-spacing: -0.04em; + line-height: 1; + color: #f8fafc; + text-shadow: 0 10px 30px rgba(34, 211, 238, 0.14); +} + +.root :global(.future-v2-hero-divider) { + width: 1px; + height: 56px; + background: rgba(255, 255, 255, 0.12); +} + +.root :global(.future-v2-hero-weather) { + display: grid; + gap: 4px; + font-size: 15px; + color: var(--text-secondary); +} + +.root :global(.future-v2-hero-icon) { + display: inline-flex; + align-items: center; + line-height: 1; +} + +.root :global(.future-v2-hero-obs) { + margin-top: 10px; + color: var(--text-secondary); + font-size: 26px; + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.root :global(.future-v2-mini-grid) { + margin-top: 14px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.root :global(.future-v2-mini-grid.future-v2-mini-grid-tight) { + margin-top: 8px; +} + +.root :global(.future-v2-mini-item) { + border-radius: 10px; + border: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.025); + padding: 10px; + display: grid; + gap: 4px; +} + +.root :global(.future-v2-mini-item span) { + color: var(--text-muted); + font-size: 11px; +} + +.root :global(.future-v2-mini-item strong) { + color: var(--text-primary); + font-size: 23px; + line-height: 1.15; + font-weight: 700; +} + +.root :global(.future-v2-card-title) { + margin: 0; + color: var(--text-primary); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.root :global(.future-v2-market-v3) { + margin-top: 16px; + display: flex; + flex-direction: column; + gap: 16px; + position: relative; +} + +.root :global(.market-layer-loading-overlay) { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(15, 23, 42, 0.6); + backdrop-filter: blur(2px); + z-index: 10; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 8px; + color: var(--accent-cyan); + font-size: 13px; + font-weight: 500; +} + +.root :global(.market-sub-title) { + color: var(--text-secondary); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; + font-weight: 600; +} + +.root :global(.market-layer-target) { + background: rgba(34, 211, 238, 0.04); + border: 1px solid rgba(34, 211, 238, 0.15); + border-radius: 8px; + padding: 12px; +} + +.root :global(.market-target-header) { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + font-size: 13px; + color: var(--text-secondary); +} + +.root :global(.market-target-bucket) { + font-size: 16px; + color: var(--text-primary); + font-weight: 700; +} + +.root :global(.market-edge-box) { + background: rgba(15, 23, 42, 0.4); + border-radius: 6px; + padding: 10px; +} + +.root :global(.market-edge-header) { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 12px; + color: var(--text-primary); + font-weight: 600; +} + +.root :global(.market-edge-val) { + font-size: 15px; + font-weight: 700; +} + +.root :global(.market-edge-val.positive) { + color: var(--accent-green); +} + +.root :global(.market-edge-val.negative) { + color: var(--text-secondary); +} + +.root :global(.market-edge-compare) { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding-top: 8px; +} + +.root :global(.edge-stat) { + display: flex; + flex-direction: column; + gap: 2px; +} + +.root :global(.edge-label) { + font-size: 10px; + color: var(--text-muted); +} + +.root :global(.edge-value) { + font-size: 13px; + color: var(--text-secondary); + font-weight: 600; +} + +.root :global(.market-layer-book), +.root :global(.market-layer-context) { + padding: 0 4px; +} + +.root :global(.market-book-row), +.root :global(.market-context-row) { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + padding: 6px 0; + border-bottom: 1px dashed rgba(255, 255, 255, 0.05); +} + +.root :global(.market-book-row:last-child), +.root :global(.market-context-row:last-child) { + border-bottom: none; +} + +.root :global(.book-label), +.root :global(.market-context-row span) { + color: var(--text-secondary); +} + +.root :global(.book-quote strong), +.root :global(.market-context-row strong) { + color: var(--text-primary); + font-weight: 600; +} + +.root :global(.book-quote span) { + color: var(--text-muted); + font-size: 11px; +} + +.root :global(.book-spread) { + color: var(--text-muted); + font-size: 11px; +} + +.root :global(.mt-3) { + margin-top: 12px; +} + +.root :global(.future-v2-market-signal) { + margin-top: 10px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(34, 211, 238, 0.08); + border: 1px solid rgba(34, 211, 238, 0.22); + color: var(--accent-cyan); + font-size: 12px; + font-weight: 600; +} + +.root :global(.future-v2-main-chart) { + min-height: 340px; +} + .root :global(.future-modal-section) { background: rgba(255, 255, 255, 0.02); border: 1px solid var(--border-subtle); @@ -1660,9 +2026,12 @@ 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) + rgba(56, 189, 248, 0.4) 0%, + rgba(56, 189, 248, 0.4) 30%, + rgba(255, 255, 255, 0.06) 45%, + rgba(255, 255, 255, 0.06) 55%, + rgba(245, 158, 11, 0.4) 70%, + rgba(245, 158, 11, 0.4) 100% ); overflow: hidden; } @@ -1675,9 +2044,11 @@ width: 14px; height: 14px; border-radius: 999px; - background: var(--accent-cyan); + background: var(--text-primary); transform: translate(-50%, -50%); - box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.14); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15); + z-index: 2; + transition: left 0.5s ease; } .root :global(.future-front-meta) { @@ -1699,6 +2070,14 @@ } @media (max-width: 960px) { + .root :global(.future-v2-layout) { + grid-template-columns: 1fr; + } + + .root :global(.future-v2-mini-item strong) { + font-size: 18px; + } + .root :global(.future-modal-grid), .root :global(.future-trend-grid) { grid-template-columns: 1fr; diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 4501805b..26f9485d 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -1,5 +1,17 @@ "use client"; +import { + Cloud, + CloudFog, + CloudLightning, + CloudRain, + CloudSnow, + CloudSun, + Search, + Sun, + Wind, +} from "lucide-react"; + import { ChartConfiguration } from "chart.js/auto"; import clsx from "clsx"; import { CSSProperties } from "react"; @@ -20,6 +32,155 @@ import { parseAiAnalysis, } from "@/lib/dashboard-utils"; +function normalizeMarketValue(value?: number | null) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100)); + return Math.max(0, Math.min(1, numeric)); +} + +function WeatherIcon({ emoji, size = 32 }: { emoji: string; size?: number }) { + if (emoji === "☀️") return ; + if (emoji === "⛅" || emoji === "🌤️") + return ; + if (emoji === "☁️") return ; + if (emoji === "🌧️" || emoji === "🌦️") + return ; + if (emoji === "⛈️") return ; + if (emoji === "❄️" || emoji === "🌨️") + return ; + if (emoji === "🌫️") return ; + if (emoji === "💨") return ; + return ; +} + +function formatMarketPercent(value?: number | null) { + const normalized = normalizeMarketValue(value); + if (normalized == null) return "--"; + return `${(normalized * 100).toFixed(1)}%`; +} + +function formatMarketPriceCents(value?: number | null) { + const normalized = normalizeMarketValue(value); + if (normalized == null) return "--"; + const cents = normalized * 100; + const rounded = Math.round(cents * 10) / 10; + return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}¢`; +} + +function formatSignedPercent(value?: number | null) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return "--"; + const sign = numeric > 0 ? "+" : ""; + return `${sign}${numeric.toFixed(1)}%`; +} + +function formatSpreadPercent(low?: number | null, high?: number | null) { + const a = normalizeMarketValue(low); + const b = normalizeMarketValue(high); + if (a == null || b == null) return "--"; + const spreadCents = Math.abs((b - a) * 100); + const rounded = Math.round(spreadCents * 10) / 10; + return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}¢`; +} + +function resolveCounterPrice( + directValue?: number | null, + mirrorValue?: number | null, +) { + const direct = normalizeMarketValue(directValue); + if (direct != null) return direct; + const mirror = normalizeMarketValue(mirrorValue); + if (mirror == null) return null; + return Math.max(0, Math.min(1, 1 - mirror)); +} + +function formatBucketLabel( + bucket?: { + label?: string | null; + bucket?: string | null; + range?: string | null; + value?: number | null; + temp?: number | null; + } | null, +) { + if (!bucket) return "--"; + const direct = + String(bucket.label || "").trim() || + String(bucket.bucket || "").trim() || + String(bucket.range || "").trim(); + if (direct) { + let str = direct.toUpperCase().replace(/\s+/g, ""); + str = str.replace(/°?C($|\+|-)/g, "℃$1"); + if (!str.includes("℃") && /[0-9]/.test(str)) { + str += "℃"; + } + return str; + } + + const temp = Number(bucket.value ?? bucket.temp); + if (Number.isFinite(temp)) { + return `${Math.round(temp)}℃`; + } + return "--"; +} + +function parseMetarSignedInt(token: string) { + if (!token) return null; + const normalized = token.toUpperCase(); + if (!/^[M]?\d{2}$/.test(normalized)) return null; + const value = Number(normalized.replace("M", "")); + if (!Number.isFinite(value)) return null; + return normalized.startsWith("M") ? -value : value; +} + +function parseMetarTempDew(rawMetar?: string | null) { + const text = String(rawMetar || "").toUpperCase(); + if (!text) + return { tempC: null as number | null, dewC: null as number | null }; + const match = text.match(/\s(M?\d{2})\/(M?\d{2})(?:\s|$)/); + if (!match) + return { tempC: null as number | null, dewC: null as number | null }; + return { + tempC: parseMetarSignedInt(match[1]), + dewC: parseMetarSignedInt(match[2]), + }; +} + +function estimateHumidityFromTempDew( + tempC?: number | null, + dewC?: number | null, +) { + const t = Number(tempC); + const d = Number(dewC); + if (!Number.isFinite(t) || !Number.isFinite(d)) return null; + const es = Math.exp((17.625 * t) / (243.04 + t)); + const ed = Math.exp((17.625 * d) / (243.04 + d)); + const rh = (ed / es) * 100; + if (!Number.isFinite(rh)) return null; + return Math.max(0, Math.min(100, rh)); +} + +function parseVisibilityText( + rawMetar?: string | null, + visibilityMi?: number | null, +) { + const direct = Number(visibilityMi); + if (Number.isFinite(direct)) { + return `${direct} mi`; + } + + const text = String(rawMetar || "").toUpperCase(); + if (!text) return "--"; + if (text.includes("CAVOK")) return ">=6 mi"; + + const sm = text.match(/\s(\d{1,2}(?:\/\d)?)SM(?:\s|$)/); + if (sm) { + return `${sm[1]} mi`; + } + return "--"; +} + function DailyTemperatureChart({ dateStr }: { dateStr: string }) { const store = useDashboardStore(); const { locale, t } = useI18n(); @@ -29,186 +190,103 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { const todayChartData = detail && isToday ? getTemperatureChartData(detail, locale) : null; - const canvasRef = useChart( - () => { - if (!detail || !view) { - return { - data: { datasets: [], labels: [] }, - type: "line", - } satisfies ChartConfiguration<"line">; - } + const canvasRef = useChart(() => { + if (!detail || !view) { + return { + data: { datasets: [], labels: [] }, + type: "line", + } satisfies ChartConfiguration<"line">; + } - if (isToday && todayChartData) { - const datasets: NonNullable["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: locale === "en-US" ? "MGM Forecast" : "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: locale === "en-US" ? "DEB Forecast" : "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: locale === "en-US" ? "DEB Forecast" : "DEB 预报", - pointRadius: 0, - tension: 0.3, - }); - } + if (isToday && todayChartData) { + const datasets: NonNullable< + ChartConfiguration<"line">["data"] + >["datasets"] = []; + if (todayChartData.datasets.hasMgmHourly) { datasets.push({ - backgroundColor: "#22d3ee", - borderColor: "#22d3ee", - borderWidth: 0, - data: todayChartData.datasets.metarPoints, + backgroundColor: "rgba(234, 179, 8, 0.05)", + borderColor: "rgba(234, 179, 8, 0.8)", + borderWidth: 2, + data: todayChartData.datasets.mgmHourlyPoints, fill: false, - label: locale === "en-US" ? "METAR Observation" : "METAR 实测", - order: 0, - pointHoverRadius: 7, - pointRadius: 5, + label: locale === "en-US" ? "MGM Forecast" : "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: locale === "en-US" ? "DEB Forecast" : "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: locale === "en-US" ? "DEB Forecast" : "DEB 预测", + pointRadius: 0, + tension: 0.3, }); - - if (todayChartData.datasets.mgmPoints.some((value) => value != null)) { - datasets.push({ - backgroundColor: "#facc15", - borderColor: "#facc15", - borderWidth: 0, - data: todayChartData.datasets.mgmPoints, - fill: false, - label: locale === "en-US" ? "MGM Observation" : "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: locale === "en-US" ? "OM Raw" : "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"; + datasets.push({ + backgroundColor: "#22d3ee", + borderColor: "#22d3ee", + borderWidth: 0, + data: todayChartData.datasets.metarPoints, + fill: false, + label: locale === "en-US" ? "METAR Observation" : "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: locale === "en-US" ? "MGM Observation" : "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: locale === "en-US" ? "OM Raw" : "OM 原始", + pointRadius: 0, + tension: 0.3, + }); + } return { data: { - datasets: [ - { - backgroundColor: "rgba(34, 211, 238, 0.08)", - borderColor: "#22d3ee", - data: view.slice.map((point) => point.temp), - fill: false, - label: locale === "en-US" ? "Open-Meteo Temperature" : "Open-Meteo 温度", - pointRadius: 2, - tension: 0.28, - }, - { - backgroundColor: "transparent", - borderColor: "#a78bfa", - borderDash: [5, 4], - data: view.slice.map((point) => point.dewPoint), - fill: false, - label: locale === "en-US" ? "Dew Point" : "露点", - pointRadius: 0, - tension: 0.24, - }, - ], - labels, + datasets, + labels: todayChartData.times, }, options: { interaction: { intersect: false, mode: "index" }, @@ -217,6 +295,16 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { 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 }, }, }, @@ -224,10 +312,6 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { 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, @@ -235,6 +319,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { 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, @@ -242,8 +330,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { }, y: { grid: { color: "rgba(255,255,255,0.04)" }, + max: todayChartData.max, + min: todayChartData.min, ticks: { - callback: (value) => `${value}${unit}`, + callback: (value) => `${value}${detail.temp_symbol || "°C"}`, color: "#64748b", font: { family: "Inter", size: 10 }, }, @@ -252,9 +342,80 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { }, type: "line", } satisfies ChartConfiguration<"line">; - }, - [detail, isToday, locale, todayChartData, view], - ); + } + + 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: + locale === "en-US" ? "Open-Meteo Temperature" : "Open-Meteo 温度", + pointRadius: 2, + tension: 0.28, + }, + { + backgroundColor: "transparent", + borderColor: "#a78bfa", + borderDash: [5, 4], + data: view.slice.map((point) => point.dewPoint), + fill: false, + label: locale === "en-US" ? "Dew Point" : "露点", + 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, locale, todayChartData, view]); return ( <> @@ -274,6 +435,7 @@ export function FutureForecastModal() { const store = useDashboardStore(); const { locale, t } = useI18n(); const detail = store.selectedDetail; + const marketScan = store.selectedMarketScan; const dateStr = store.futureModalDate; if (!detail || !dateStr) return null; @@ -289,6 +451,88 @@ export function FutureForecastModal() { "--score-position": scorePosition, } as CSSProperties & { "--score-position": string }; const weatherSummary = getWeatherSummary(detail, locale); + const marketMidpoint = formatMarketPercent( + marketScan?.market_price ?? marketScan?.yes_token?.implied_probability, + ); + const modelProbability = formatMarketPercent(marketScan?.model_probability); + const marketYesBuy = formatMarketPriceCents(marketScan?.yes_buy); + const marketYesSell = formatMarketPriceCents(marketScan?.yes_sell); + const marketNoBuy = formatMarketPriceCents( + resolveCounterPrice(marketScan?.no_buy, marketScan?.yes_buy), + ); + const marketNoSell = formatMarketPriceCents( + resolveCounterPrice(marketScan?.no_sell, marketScan?.yes_sell), + ); + const marketEdge = formatSignedPercent(marketScan?.edge_percent); + const marketSpread = formatSpreadPercent( + marketScan?.yes_buy, + marketScan?.yes_sell, + ); + const topBucket = Array.isArray(marketScan?.top_buckets) + ? [...marketScan.top_buckets] + .map((item) => ({ + ...item, + probability: normalizeMarketValue(item?.probability), + })) + .filter( + ( + item, + ): item is { + label?: string | null; + bucket?: string | null; + range?: string | null; + value?: number | null; + temp?: number | null; + probability: number; + } => item.probability != null, + ) + .sort((a, b) => b.probability - a.probability)[0] + : null; + const settlementBucketLabel = formatBucketLabel( + marketScan?.temperature_bucket, + ); + const hottestBucketLabel = formatBucketLabel(topBucket); + const hottestBucketProb = formatMarketPercent(topBucket?.probability); + const marketSignal = marketScan?.signal_label + ? `${marketScan.signal_label}${ + marketScan.confidence ? ` / ${marketScan.confidence}` : "" + }` + : "--"; + const metarParsed = parseMetarTempDew(detail.current?.raw_metar); + const fallbackDewpoint = + detail.current?.dewpoint ?? + metarParsed.dewC ?? + (Array.isArray(detail.hourly_next_48h?.dew_point) + ? detail.hourly_next_48h?.dew_point?.[0] + : null); + const fallbackHumidity = + detail.current?.humidity ?? + estimateHumidityFromTempDew( + detail.current?.temp ?? metarParsed.tempC, + fallbackDewpoint, + ); + const topObservedTemp = + detail.current?.max_so_far != null + ? detail.current.max_so_far + : detail.current?.temp; + const currentTempText = + detail.current?.temp != null + ? `${detail.current.temp}${detail.temp_symbol}` + : "--"; + const humidityText = + fallbackHumidity != null ? `${Math.round(fallbackHumidity)}%` : "--"; + const dewpointText = + fallbackDewpoint != null + ? `${fallbackDewpoint}${detail.temp_symbol}` + : "--"; + const windText = + detail.current?.wind_speed_kt != null + ? `${detail.current.wind_speed_kt} kt` + : "--"; + const visibilityText = parseVisibilityText( + detail.current?.raw_metar, + detail.current?.visibility_mi, + ); return (
-

- {isToday - ? t("future.todayTitle", { - city: detail.display_name.toUpperCase(), - }) - : t("future.dateTitle", { - city: detail.display_name.toUpperCase(), - date: dateStr, - })} +

+ + {isToday + ? t("future.todayTitle", { + city: detail.display_name.toUpperCase(), + }) + : t("future.dateTitle", { + city: detail.display_name.toUpperCase(), + date: dateStr, + })} + +

-
- {isToday && ( - <> -
- {t("future.currentObs")} - - {detail.current?.temp ?? "--"} - {detail.temp_symbol} @{detail.current?.obs_time || "--"} - + {isToday ? ( +
+ + +
+
+

+ {locale === "en-US" + ? "Today's temperature forecast (obs + market)" + : "今日气温预测(观测 + 市场)"} +

+ +
+ +
+
+

{t("future.probability")}

+
+ {/* Loading Overlay */} + {store.loadingState.marketScan && ( +
+
+ {locale === "en-US" + ? "Crunching Polymarket Edges..." + : "正在同步市场挂单..."} +
+ )} + +
+
+
+

{t("future.models")}

+ +
-
- {t("future.currentWeather")} - - {weatherSummary.weatherIcon} {weatherSummary.weatherText} - + +
+
+

{t("future.structureToday")}

+
+
+
+
+
+ + {t("future.judgement")}: {view.front.label} + + + {t("future.confidence")}:{" "} + {t(`confidence.${view.front.confidence}`)} + + + {t("future.maxPrecip")}:{" "} + {Math.round(view.front.precipMax)}% + +
+
+
+ {view.front.metrics.slice(0, 6).map((metric) => ( +
+
+ {metric.label} +
+
+ {metric.value} +
+
{metric.note}
+
+ ))} +
+
+ +
+

{t("future.ai")}

+
+ {ai.summary ? ( +
{ai.summary}
+ ) : ( +
{t("future.noAi")}
+ )} + {ai.bullets.length > 0 && ( +
+ {ai.bullets.slice(0, 3).map((item) => ( +
{item}
+ ))} +
+ )} +
+ {nowcastRows.slice(0, 4).map(([label, value]) => ( +
+ {label}: + {value} +
+ ))} +
+ {riskLines.length > 0 && ( +
+ + {locale === "en-US" ? "Risk" : "风险"}:{" "} + + {riskLines[0]} +
+ )} + {climateDrivers.length > 0 && ( +
+ + {locale === "en-US" ? "Climate" : "气候"}:{" "} + + {climateDrivers[0].text} +
+ )} +
+
+
+
+ ) : ( + <> +
- {t("future.wuRef")} + {t("future.targetForecast")} - {detail.current?.wu_settlement ?? "--"} + {view.forecastEntry?.max_temp ?? "--"} {detail.temp_symbol}
- {t("future.sunrise")} - {detail.forecast?.sunrise || "--"} -
-
- {t("future.sunset")} - {detail.forecast?.sunset || "--"} -
-
- {t("future.sunshine")} + {t("future.deb")} - {detail.forecast?.sunshine_hours != null - ? `${detail.forecast.sunshine_hours}h` + {view.deb ?? "--"} + {detail.temp_symbol} + +
+
+ {t("future.mu")} + + {view.mu != null + ? `${view.mu.toFixed(1)}${detail.temp_symbol}` : "--"}
- - )} - -
- - {isToday ? t("future.todayForecastHigh") : t("future.targetForecast")} - - - {view.forecastEntry?.max_temp ?? "--"} - {detail.temp_symbol} - -
-
- {t("future.deb")} - - {view.deb ?? "--"} - {detail.temp_symbol} - -
-
- {t("future.mu")} - - {view.mu != null ? `${view.mu.toFixed(1)}${detail.temp_symbol}` : "--"} - -
-
- {t("future.score")} - - {view.front.score > 0 ? "+" : ""} - {view.front.score} - -
-
- -
-

{isToday ? t("future.todayTempTrend") : t("future.targetTempTrend")}

- -
- -
-
-

{t("future.probability")}

- -
-
-

{t("future.models")}

- -
-
- -
-
-

- - {isToday ? t("future.structureToday") : t("future.structureDate")} -

-
-
-
- - {t("future.judgement")}: {view.front.label} - - - {t("future.confidence")}:{" "} - {t(`confidence.${view.front.confidence}`)} - - - {t("future.maxPrecip")}: {Math.round(view.front.precipMax)}% +
+ {t("future.score")} + + {view.front.score > 0 ? "+" : ""} + {view.front.score}
-
{view.front.summary}
-
- {view.front.metrics.map((metric) => ( -
-
{metric.label}
-
- {metric.value} -
-
{metric.note}
-
- ))} -
-
- -
-

{t("future.ai")}

-
- {ai.summary ?
{ai.summary}
: null} - - {ai.bullets.length > 0 && ( -
- {ai.bullets.map((item) => ( -
{item}
- ))} -
- )} - - {!ai.summary && ai.bullets.length === 0 && ( -
{t("future.noAi")}
- )} - -
- {nowcastRows.map(([label, value]) => ( -
- {label}: - {value} -
- ))} -
- - {view.front.weatherGovPeriods.length > 0 && ( -
- {t("future.weatherGov")}: - {view.front.weatherGovPeriods - .map((period) => period.short_forecast || period.detailed_forecast) - .filter(Boolean) - .join(" / ")} -
- )} -
-
-
- - {isToday && ( -
-
-

{t("future.risk")}

-
- {riskLines.map((line) => ( -
- - • - - {line} -
- ))} -
-
-

{t("future.climate")}

-
- {climateDrivers.map((driver) => ( -
-
{driver.label}
-
{driver.text}
-
- ))} -
+

{t("future.targetTempTrend")}

+
-
+ +
+
+

{t("future.probability")}

+ +
+
+

{t("future.models")}

+ +
+
+ )}
diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index 67eade8d..7c12024d 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -5,7 +5,12 @@ import clsx from "clsx"; import { useChart } from "@/hooks/useChart"; import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; -import { CityDetail } from "@/lib/dashboard-types"; +import { + CityDetail, + MarketScan, + MarketTopBucket, + ProbabilityBucket, +} from "@/lib/dashboard-types"; import { getHeroMetaItems, getModelView, @@ -17,7 +22,101 @@ import { } from "@/lib/dashboard-utils"; function EmptyState({ text }: { text: string }) { - return
{text}
; + return ( +
{text}
+ ); +} + +function toPercent(value?: number | null) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + return `${(numeric * 100).toFixed(1)}%`; +} + +function toPriceCents(value?: number | null) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + const normalized = numeric > 1 ? numeric / 100 : numeric; + const cents = normalized * 100; + const rounded = Math.round(cents * 10) / 10; + const text = Number.isInteger(rounded) + ? String(rounded.toFixed(0)) + : String(rounded); + return `${text}c`; +} + +function parseTempFromText(value: unknown) { + const text = String(value || ""); + const match = text.match(/(-?\d+(?:\.\d+)?)/); + if (!match) return null; + const numeric = Number(match[1]); + return Number.isFinite(numeric) ? numeric : null; +} + +function getBucketTemp(bucket: ProbabilityBucket) { + const byValue = Number(bucket.value); + if (Number.isFinite(byValue)) return byValue; + return parseTempFromText(bucket.label || bucket.bucket || bucket.range); +} + +function getMarketBucketTemp(scan?: MarketScan | null) { + if (!scan) return null; + + const byBucketValue = Number(scan.temperature_bucket?.value); + if (Number.isFinite(byBucketValue)) return byBucketValue; + + const byBucketLabel = parseTempFromText( + scan.temperature_bucket?.label || + scan.temperature_bucket?.bucket || + scan.temperature_bucket?.range, + ); + if (byBucketLabel != null) return byBucketLabel; + + const slug = String(scan.selected_slug || scan.primary_market?.slug || ""); + const slugMatch = slug.match(/-(-?\d+(?:\.\d+)?)c(?:$|[^a-z0-9])/i); + if (slugMatch) { + const numeric = Number(slugMatch[1]); + if (Number.isFinite(numeric)) return numeric; + } + + return parseTempFromText(scan.primary_market?.question); +} + +function getMarketYesPrice(scan?: MarketScan | null) { + const preferred = Number(scan?.market_price); + if (Number.isFinite(preferred)) return preferred; + const implied = Number(scan?.yes_token?.implied_probability); + return Number.isFinite(implied) ? implied : null; +} + +function getMarketNoPrice(scan?: MarketScan | null) { + const direct = Number(scan?.no_buy); + if (Number.isFinite(direct)) return direct; + const marketYes = getMarketYesPrice(scan); + if (marketYes != null) return Math.max(0, Math.min(1, 1 - marketYes)); + return null; +} + +function normalizeMarketProbability(value?: number | null) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100)); + return Math.max(0, Math.min(1, numeric)); +} + +function getMarketTopBuckets(scan?: MarketScan | null) { + const buckets = Array.isArray(scan?.top_buckets) ? scan.top_buckets : []; + if (!buckets.length) return []; + + return buckets + .map((item) => ({ + ...item, + probability: normalizeMarketProbability(item.probability), + })) + .filter( + (item): item is MarketTopBucket & { probability: number } => + item.probability != null, + ); } export function HeroSummary() { @@ -55,7 +154,9 @@ export function HeroSummary() {
- {locale === "en-US" ? "Current Obs" : "当前实测"} + + {locale === "en-US" ? "Current Obs" : "当前实测"} + {current.temp != null ? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}` @@ -73,7 +174,9 @@ export function HeroSummary() {
- {locale === "en-US" ? "DEB Forecast" : "DEB 预测"} + + {locale === "en-US" ? "DEB Forecast" : "DEB 预测"} + {data.deb?.prediction != null ? `${data.deb.prediction}${data.temp_symbol}` @@ -95,142 +198,141 @@ export function TemperatureChart() { const { locale, t } = useI18n(); const chartData = data ? getTemperatureChartData(data, locale) : null; - const canvasRef = useChart( - () => { - if (!data || !chartData) { - return { - data: { datasets: [], labels: [] }, - type: "line", - } satisfies ChartConfiguration<"line">; - } - - const datasets: NonNullable["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: locale === "en-US" ? "MGM Forecast" : "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: locale === "en-US" ? "DEB Forecast" : "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: locale === "en-US" ? "DEB Forecast" : "DEB 预报", - pointRadius: 0, - tension: 0.3, - }); - } - - datasets.push({ - backgroundColor: "#22d3ee", - borderColor: "#22d3ee", - borderWidth: 0, - data: chartData.datasets.metarPoints, - fill: false, - label: locale === "en-US" ? "METAR Observation" : "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: locale === "en-US" ? "MGM Observation" : "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: locale === "en-US" ? "OM Raw" : "OM 原始", - pointRadius: 0, - tension: 0.3, - }); - } - + const canvasRef = useChart(() => { + if (!data || !chartData) { 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", - }, - }, - }, - }, + data: { datasets: [], labels: [] }, type: "line", } satisfies ChartConfiguration<"line">; - }, - [data, chartData, locale], - ); + } + + 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: locale === "en-US" ? "MGM Forecast" : "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: locale === "en-US" ? "DEB Forecast" : "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: locale === "en-US" ? "DEB Forecast" : "DEB 预报", + pointRadius: 0, + tension: 0.3, + }); + } + + datasets.push({ + backgroundColor: "#22d3ee", + borderColor: "#22d3ee", + borderWidth: 0, + data: chartData.datasets.metarPoints, + fill: false, + label: locale === "en-US" ? "METAR Observation" : "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: locale === "en-US" ? "MGM Observation" : "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: locale === "en-US" ? "OM Raw" : "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, locale]); return (
@@ -238,7 +340,9 @@ export function TemperatureChart() {
-
{chartData?.legendText || t("section.chartEmpty")}
+
+ {chartData?.legendText || t("section.chartEmpty")} +
); } @@ -247,13 +351,28 @@ export function ProbabilityDistribution({ detail, hideTitle = false, targetDate, + marketScan, }: { detail: CityDetail; hideTitle?: boolean; targetDate?: string | null; + marketScan?: MarketScan | null; }) { - const { t } = useI18n(); + const { locale, t } = useI18n(); const view = getProbabilityView(detail, targetDate); + const marketBucketTemp = getMarketBucketTemp(marketScan); + const marketYesPrice = getMarketYesPrice(marketScan); + const marketNoPrice = getMarketNoPrice(marketScan); + const marketYesText = toPercent(marketYesPrice); + const marketNoText = toPercent(marketNoPrice); + const isToday = !targetDate || targetDate === detail.local_date; + const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : []; + const sortedMarketTopBuckets = [...marketTopBuckets] + .sort((a, b) => Number(b.probability || 0) - Number(a.probability || 0)) + .slice(0, 4); + const useMarketTopBuckets = + marketScan?.available && sortedMarketTopBuckets.length > 0; + const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability); return (
@@ -261,7 +380,11 @@ export function ProbabilityDistribution({
{view.mu != null && (
{t("section.mu", { unit: detail.temp_symbol || "", @@ -269,16 +392,62 @@ export function ProbabilityDistribution({ })}
)} - {view.probabilities.length === 0 ? ( - - ) : ( - view.probabilities.slice(0, 6).map((bucket, index) => { - const probability = Math.round(Number(bucket.probability || 0) * 100); + {marketScan?.available && (topMarketBucketText || marketYesText) && ( +
+ {useMarketTopBuckets + ? locale === "en-US" + ? `Market top-4 buckets (top): ${topMarketBucketText}` + : `市场概率(前4温度桶):最高 ${topMarketBucketText}` + : locale === "en-US" + ? `Market probability (this bucket): ${marketYesText}` + : `市场概率(该温度桶): ${marketYesText}`} +
+ )} + {useMarketTopBuckets ? ( + sortedMarketTopBuckets.map((bucket, index) => { + const probability = Math.round( + Number(bucket.probability || 0) * 100, + ); + let bucketLabel = + bucket.label || + (bucket.value != null + ? `${bucket.value}${detail.temp_symbol}` + : `${bucket.temp ?? "--"}${detail.temp_symbol}`); + + if (bucketLabel) { + let str = String(bucketLabel).toUpperCase().replace(/\s+/g, ""); + str = str.replace(/°?C($|\+|-)/g, "℃$1"); + if (!str.includes("℃") && /[0-9]/.test(str)) { + str += "℃"; + } + bucketLabel = str; + } + const buyYesText = toPriceCents( + bucket.yes_buy ?? bucket.market_price ?? bucket.probability, + ); + const buyNoText = toPriceCents(bucket.no_buy); + const marketTag = buyYesText + ? locale === "en-US" + ? `Buy Yes: ${buyYesText}` + : `买 Yes: ${buyYesText}` + : buyNoText + ? locale === "en-US" + ? `Buy No: ${buyNoText}` + : `买 No: ${buyNoText}` + : null; + return ( -
-
- {bucket.label || `${bucket.value}${detail.temp_symbol}`} -
+
+
{bucketLabel}
+ {marketTag && ( +
+ {marketTag} +
+ )} +
+ ); + }) + ) : view.probabilities.length === 0 ? ( + + ) : ( + view.probabilities.slice(0, 6).map((bucket, index) => { + const probability = Math.round( + Number(bucket.probability || 0) * 100, + ); + const bucketTemp = getBucketTemp(bucket); + const isMarketBucket = + marketYesText != null && + marketBucketTemp != null && + bucketTemp != null && + Math.abs(bucketTemp - marketBucketTemp) < 0.26; + const marketTag = isMarketBucket + ? locale === "en-US" + ? `Buy Yes: ${marketYesText || "--"}` + : `买 Yes: ${marketYesText || "--"}` + : marketNoText + ? locale === "en-US" + ? `Buy No: ${marketNoText}` + : `买 No: ${marketNoText}` + : null; + const yesPriceText = toPriceCents(marketYesPrice); + const noPriceText = toPriceCents(marketNoPrice); + const marketTagFinal = isMarketBucket + ? locale === "en-US" + ? `Buy Yes: ${yesPriceText || "--"}` + : `买 Yes: ${yesPriceText || "--"}` + : noPriceText + ? locale === "en-US" + ? `Buy No: ${noPriceText}` + : `买 No: ${noPriceText}` + : marketTag; + let bucketLabel = + bucket.label || `${bucket.value}${detail.temp_symbol}`; + if (bucketLabel) { + let str = String(bucketLabel).toUpperCase().replace(/\s+/g, ""); + str = str.replace(/°?C($|\+|-)/g, "℃$1"); + if (!str.includes("℃") && /[0-9]/.test(str)) { + str += "℃"; + } + bucketLabel = str; + } + + return ( +
+
{bucketLabel}
+
+
+ {probability}% +
+
+ {marketTagFinal && ( +
+ {marketTagFinal} +
+ )}
); }) @@ -313,8 +558,12 @@ export function ModelForecast({ 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 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 ( @@ -341,12 +590,18 @@ export function ModelForecast({ {name}
-
+
{numeric} {detail.temp_symbol}
{debLine != null && ( -
+
)}
@@ -410,13 +665,19 @@ export function ForecastTable() {