更新 CLAUDE.md,清理死 import,图表与 API 细节调整
This commit is contained in:
@@ -4,168 +4,133 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
PolyWeather Pro — a production weather-intelligence stack for temperature settlement markets. Aggregates observations and forecasts for 52 monitored cities globally, blends multi-model highs using DEB (Dynamic Error Balancing), generates calibrated probability buckets for settlement, and serves both a Next.js dashboard (Vercel) and a Telegram bot.
|
||||
PolyWeather Pro — a paid institutional weather-intelligence terminal for temperature settlement markets. 50 monitored cities, DEB multi-model temperature blending, Mu probability calibration, Polymarket CLOB/WS price integration. Next.js 15 + React 19 (Vercel) frontend, FastAPI backend (VPS), Telegram bot.
|
||||
|
||||
## Environment & Preferences (ALWAYS follow)
|
||||
**Business model**: Paid-only, $10/month, no free tier, no trial. Landing page is public; `/terminal` requires login + active subscription.
|
||||
|
||||
### Working Directory
|
||||
- All commands run from the repo root
|
||||
- Python virtual env: `venv\Scripts\activate` (Windows) / `source venv/bin/activate` (Linux/macOS)
|
||||
- Frontend dev server: `cd frontend && npm run dev` → http://localhost:3000
|
||||
- Backend API server: `uvicorn web.app:app --reload --host 0.0.0.0 --port 8000` → http://localhost:8000
|
||||
- When I say "start the server", assume the working directory is the repo root
|
||||
## Environment & Preferences
|
||||
|
||||
### Git Conventions
|
||||
- **Commit language: Chinese (简体中文) ONLY**
|
||||
- Format: Lore Commit Protocol — intent line in Chinese, trailers in English
|
||||
- Examples: `重构城市决策卡 hero 布局` or `统一 DEB 数据源为单一计算路径`
|
||||
- **NEVER** use English for commit subject lines
|
||||
|
||||
### Tooling
|
||||
- Package manager: **npm** (not yarn/pnpm)
|
||||
- Working directory: repo root
|
||||
- Python: `python` (not python3), venv at `venv/`
|
||||
- Lint: `ruff check .` (Python) + `npx tsc --noEmit` (TypeScript)
|
||||
- NEVER ask me about these preferences again — commit to memory
|
||||
- Frontend: `cd frontend && npm run dev` → localhost:3000
|
||||
- Backend: `uvicorn web.app:app --reload --host 0.0.0.0 --port 8000`
|
||||
- Package manager: **npm** (not yarn/pnpm)
|
||||
- **Commit language: Chinese (简体中文) ONLY**
|
||||
- **NEVER start commit messages with `@`** — Chinese directly, no prefix
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm run dev # dev server :3000
|
||||
npm run build # production build
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run test:business # 21 business state tests
|
||||
|
||||
# Backend
|
||||
uvicorn web.app:app --reload --host 0.0.0.0 --port 8000
|
||||
python bot_listener.py # Telegram bot
|
||||
|
||||
# Python tests
|
||||
python -m pytest tests/
|
||||
python -m pytest tests/test_supabase_entitlement.py
|
||||
|
||||
# Lint
|
||||
ruff check .
|
||||
ruff format .
|
||||
|
||||
# Docker (VPS)
|
||||
docker compose down && docker compose up -d --build
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Users (Web / Telegram) → Next.js Frontend (Vercel) → FastAPI /web/app.py
|
||||
↓
|
||||
Weather Collector (METAR, TAF, Open-Meteo, country networks)
|
||||
↓
|
||||
Analysis (DEB + Trend + Probability + Market Scan)
|
||||
↓
|
||||
Payment Layer (Intent + Event + Confirm Loop)
|
||||
Users → Next.js (Vercel) → FastAPI :8000 (VPS)
|
||||
/terminal (paid gate) Weather Collector
|
||||
/ (landing page) Analysis (DEB + Mu + Polymarket scan)
|
||||
Payment Layer (USDC on Polygon)
|
||||
Telegram Bot → bot_listener.py
|
||||
```
|
||||
|
||||
- **Backend**: FastAPI on port 8000 (`web/app.py` → `web/app_factory.py` → `web/routers/` (8 route modules: `system`, `city`, `auth`, `analytics`, `scan`, `payments`, `ops`, `routes` (legacy)) + `web/services/` (14 service modules) + `web/core.py`)
|
||||
- **Frontend**: Next.js 15 + React 19 + TypeScript + Tailwind CSS 3 + shadcn/ui (new-york style) on port 3000 (dev)
|
||||
- **Bot**: Telegram bot via `bot_listener.py` → `src/bot/`
|
||||
- **Shared analysis core** in `src/` is used by both web API and bot
|
||||
- **Scan Terminal**: Real-time city opportunity scanning (`web/scan_terminal_service.py` and `frontend/components/dashboard/scan-terminal/`)
|
||||
- **Dashboard**: Main dashboard with interactive map, city sidebar, detail panels, and probability views
|
||||
- **Market Monitor** (`MonitorPanel`): Real-time temperature monitoring board for 22 trading cities. Uses a temperature resolution chain (AMOS runway → AMOS → `airport_primary` → `airport_current` → `current`) defined in `frontend/components/dashboard/monitoring/monitor-temperature.ts`. Per-city refresh decisions driven by source-aware freshness (`source-freshness.ts`) instead of uniform `obs_age_min`. Seoul/Busan display runway surface temperature from AMOS; US cities get 5-min MADIS HFMETAR via `airport_primary`; others fall back to METAR.
|
||||
- **High-Freq Airport Pipeline**: 19 of 22 monitor cities have dedicated realtime sources (AMOS, MADIS, JMA, MGM, FMI, KNMI, AROME). Data flows: `weather_sources.py` (fetch) → `country_networks.py` (`_airport_primary_from_raw`, per-country providers) → API `airport_primary` field. Plain METAR stays in `airport_current`. Documented in `docs/AIRPORT_REALTIME_SOURCES.md`.
|
||||
- **Country Network Providers**: `country_networks.py` routes per-city to the right provider (Turkey→MGM, Korea→KMA, Japan→JMA, etc.) via `get_country_network_provider()`. Each provider controls `airport_primary_current`, `official_nearby_current`, and `official_network_status`. US cities use the default `GlobalMetarNetworkProvider` but get MADIS overrides injected via `results["madis_hfmetar_current"]`.
|
||||
### Frontend Structure
|
||||
|
||||
## Commands
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `app/page.tsx` | Landing page (`InstitutionalLandingPage`) |
|
||||
| `app/terminal/page.tsx` | Paid terminal (`ScanTerminalDashboard`) |
|
||||
| `app/account/` | Account center with payment/subscription |
|
||||
| `app/auth/` | Supabase login/signup |
|
||||
| `components/dashboard/scan-terminal/` | Terminal sub-components |
|
||||
| `components/account/` | Account + payment hooks |
|
||||
| `components/landing/` | Institutional landing page |
|
||||
| `components/subscription/` | `UnlockProOverlay` payment overlay |
|
||||
| `lib/dashboard-types.ts` | All TypeScript types |
|
||||
|
||||
### Frontend (dev on port 3000)
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run dev # Next.js dev server (runs sync-next-server-chunks.mjs first)
|
||||
npm run build # Production build (runs sync-next-server-chunks.mjs after)
|
||||
npm run start # Production server
|
||||
npm run lint # ESLint via next lint
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run test:business # Business state tests via scripts/run-business-state-tests.mjs (also runs in CI)
|
||||
### Terminal Component Map
|
||||
|
||||
- `ScanTerminalDashboard.tsx` — entry, auth gate, `ProductAccessRequired`
|
||||
- `PolyWeatherTerminal` — main layout: sidebar + region tabs + 2-column grid
|
||||
- `CityRegionList` — city list panel (left top)
|
||||
- `CityContractDetail` — contract table panel (left bottom)
|
||||
- `LiveTemperatureThresholdChart` — temperature trend + market thresholds (right)
|
||||
- `TrainingDashboard` — DEB + Mu accuracy charts (sidebar tab)
|
||||
- `MarketOverviewView` — regional heat + top opportunities (sidebar tab)
|
||||
- `GroupedMarketTable` — contract comparison table
|
||||
- `continent-grouping.ts` — 7 trading regions, city-to-region mapping, timezone detection
|
||||
|
||||
### Account Module
|
||||
|
||||
- `AccountCenter.tsx` (~1280 lines) — main component
|
||||
- `useAccountPayment.ts` — master payment hook, composes sub-hooks
|
||||
- `useWalletBind.ts` — EVM/WalletConnect binding
|
||||
- `usePaymentFlow.ts` — intent creation, payment, confirmation
|
||||
- `useBilling.ts` — subscription recovery, billing computation
|
||||
|
||||
### Backend Key Files
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `web/routers/city.py` | City detail/summary/market-scan endpoints |
|
||||
| `web/routers/scan.py` | Scan terminal aggregation |
|
||||
| `web/services/city_payloads.py` | Market scan with Polymarket integration |
|
||||
| `web/scan_terminal_city_row.py` | Builds terminal rows from analysis data |
|
||||
| `src/data_collection/city_registry.py` | 50-city registry with tz_offset |
|
||||
| `src/data_collection/polymarket_readonly.py` | Market discovery, CLOB prices, WS cache |
|
||||
| `src/data_collection/polymarket_ws_cache.py` | WebSocket quote cache |
|
||||
| `src/analysis/deb_algorithm.py` | DEB prediction + Mu calibration + accuracy |
|
||||
|
||||
## Auth Gating
|
||||
|
||||
Middleware (`middleware.ts`) handles two layers:
|
||||
1. **Terminal gate** (`handleTerminalGate`): `/terminal/*` → redirect to `/auth/login` if no Supabase session
|
||||
2. **Global auth** (`handleSupabaseAuthGate`): enforced when `POLYWEATHER_AUTH_REQUIRED=true`
|
||||
|
||||
Client-side gate (`ProductAccessRequired`): `/terminal` checks auth + subscription via `/api/auth/me`, shows paywall if needed.
|
||||
|
||||
Local dev bypass: set `NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false` to test auth locally.
|
||||
|
||||
## Polymarket Integration
|
||||
|
||||
Price pipeline:
|
||||
```
|
||||
Gamma API (slug discovery) → CLOB REST + WS cache → market_scan → terminal rows
|
||||
```
|
||||
|
||||
### Backend (dev on port 8000)
|
||||
```bash
|
||||
uvicorn web.app:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
- `resolve_city_clob_tokens()` — timezone-aware market discovery
|
||||
- `collect_all_clob_token_ids()` — all YES/NO tokens for WS subscription
|
||||
- `PolymarketWsQuoteCache` — WebSocket quote cache (daemon thread)
|
||||
- Prices flow to terminal via `**row` spread in `_build_terminal_row`
|
||||
|
||||
### Telegram Bot
|
||||
```bash
|
||||
python bot_listener.py
|
||||
# or via wrapper:
|
||||
python run.py
|
||||
```
|
||||
## Trading Regions
|
||||
|
||||
### Docker (production-like stack)
|
||||
```bash
|
||||
docker compose up -d --build # bot + web API (polyweather + polyweather_web)
|
||||
```
|
||||
The compose file defines two services: `polyweather` (bot) and `polyweather_web` (FastAPI on :8000). Prewarm worker and monitoring profiles were removed in v1.6.0.
|
||||
|
||||
### Python tests
|
||||
```bash
|
||||
python -m pytest tests/ # all tests
|
||||
python -m pytest tests/test_web_observability.py # single test file
|
||||
```
|
||||
|
||||
### Version bump (see RELEASE.md)
|
||||
```bash
|
||||
python scripts/bump_version.py patch # or minor / major / 1.5.0
|
||||
python scripts/sync_version.py # verify sync across files
|
||||
```
|
||||
`VERSION` file is the single source of truth; frontend `package.json` and docs sync from it.
|
||||
|
||||
### Lint & Format
|
||||
```bash
|
||||
ruff check . # Python lint (pycodestyle + Pyflakes, line-length 88)
|
||||
ruff format . # Python format (Black-compatible, double quotes)
|
||||
```
|
||||
|
||||
### Health & Ops checks
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/healthz
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
|
||||
## Key Directories
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `src/analysis/` | DEB algorithm, trend engine, market alert engine, settlement rounding |
|
||||
| `src/auth/` | Supabase entitlement checks, Telegram group pricing |
|
||||
| `src/bot/` | Telegram bot handlers and orchestrator |
|
||||
| `src/database/` | SQLite-based runtime state, DB manager, daily/truth/training feature repositories |
|
||||
| `src/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer. Also: `madis_sources.py` (NOAA 5-min NetCDF), `amos_station_sources.py` (Korean runway sensors), `country_networks.py` (per-country provider routing + `_airport_primary_from_raw`) |
|
||||
| `src/data_mining/` | Historical data fetch utilities |
|
||||
| `src/onchain/` | Polygon wallet watcher |
|
||||
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
|
||||
| `src/strategy/` | Trading strategy modules |
|
||||
| `src/trading/` | Trading execution modules |
|
||||
| `src/utils/` | Shared utilities: config loader, logging, metrics, Telegram push, chat ID helpers |
|
||||
| `web/` | FastAPI app (`app.py` → `app_factory.py`), `routers/` (8 route modules), `services/` (14 service modules), `core.py`, scan terminal modules (AI fallback, AI prompts, METAR gate, city rows, ranker, cache) |
|
||||
| `frontend/app/` | Next.js App Router pages (dashboard, account, auth, docs, ops, probabilities, scan) |
|
||||
| `frontend/components/dashboard/` | Dashboard UI components (map, sidebar, detail panel, modals, charts, scan terminal). `scan-root-styles.ts` is the CSS Module barrel, combining 22 module roots into one pre-composed className. `monitoring/` subdirectory: `MonitorPanel`, `monitor-temperature.ts` (temp resolution chain), `monitor-refresh-policy.ts`. |
|
||||
| `frontend/lib/` | Shared client logic: types (`dashboard-types.ts`, including `AirportCurrentConditions`, `CityDetail`), API client, chart utils, i18n, `source-freshness.ts` (per-source freshness with `expected_next_update_at`), dashboard utils |
|
||||
| `frontend/hooks/` | React hooks: dashboard store (global state), Leaflet map, chart helper |
|
||||
| `scripts/` | Operational scripts: backfills, payment reconciliation. `supabase/` subdirectory: DB schema and migration SQL. |
|
||||
| `config/` | YAML config (city list, weather settings, logging) |
|
||||
| `docs/` | Bilingual product & technical docs |
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
- **Python version**: 3.11 (target), type hints use `from __future__ import annotations` in most modules
|
||||
- **Package manager**: pip (requirements.txt) + uv cache is present but not the primary tool; no pyproject.toml build system defined
|
||||
- **Frontend package manager**: npm
|
||||
- **State storage**: SQLite primary path (set via `POLYWEATHER_STATE_STORAGE_MODE=sqlite` + `POLYWEATHER_DB_PATH`). Legacy JSON/JSONL files are migration/fallback only.
|
||||
- **Runtime data**: External dir recommended (`POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather`) to avoid git conflicts
|
||||
- **Configuration**: `.env.example` is the comprehensive reference (8 config sections: runtime, Telegram, weather cache, auth, ops, frontend, optional modules, Polygon monitor). Copy to `.env` and fill in secrets.
|
||||
- **Auth gating** (frontend middleware): Three-tier priority in `middleware.ts` — (1) local dev hosts (localhost / 127.0.0.1 / ::1) bypass auth entirely, (2) Supabase session-based when `POLYWEATHER_AUTH_ENABLED=true` via `handleSupabaseAuthGate` or `handleSupabaseOptionalSession`, (3) legacy token fallback via `POLYWEATHER_DASHBOARD_ACCESS_TOKEN` cookie/query-param. Public pages (`/`, `/docs`, `/auth/*`, `/entitlement-required`) and public API routes are always accessible.
|
||||
- **CORS**: Allowed origins from `WEB_CORS_ORIGINS` env var (defaults: localhost:3000, polyweather-pro.vercel.app)
|
||||
- **API proxy**: Frontend uses Next.js rewrites to proxy `/api/*` to the FastAPI backend; see `frontend/lib/api-proxy.ts` and `frontend/lib/backend-api.ts`
|
||||
|
||||
## Commit Convention
|
||||
|
||||
This repo uses the **Lore Commit Protocol** — structured decision records with git trailers (`Constraint:`, `Rejected:`, `Confidence:`, `Scope-risk:`, `Directive:`, `Tested:`, `Not-tested:`). Intent line first (why, not what).
|
||||
|
||||
- Always write git commit messages in **Chinese (简体中文)**.
|
||||
7 regions: east_asia, southeast_asia, central_asia, west_asia, europe_africa, south_america, north_america. Mappings in `continent-grouping.ts` (`CITY_REGION_FALLBACK` — all 50 cities hardcoded) and `scan_terminal_filters.py` (`market_region_from_tz_offset`). Default region auto-detected from browser timezone.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Never use Unicode escape sequences (`\uXXXX`) in source code; write characters directly in UTF-8 encoding.
|
||||
- When modifying UI components, update both **dark-mode and light-mode CSS files** in the same edit batch.
|
||||
- **CSS Variables First**: Prefer `var(--color-*)` / `var(--color-signal-*)` tokens over hardcoded hex values. The token system is defined in `globals.css` with light-theme overrides under `html.light`.
|
||||
- **Avoid `!important`**: Only use it for Leaflet map overrides (inline style conflict) and chart canvas sizing. For light-theme overrides, use `html.light .root` prefix for higher specificity.
|
||||
- **Monitoring CSS note**: `MonitorPanel.module.css` scopes its light-theme overrides to `.scan-terminal.light` (the terminal's built-in toggle), NOT `html.light`. When adding light styles for monitoring components, match this scoping.
|
||||
- **New CSS Modules**: Add the module root class to `scan-root-styles.ts` barrel file instead of importing it separately in `ScanTerminalDashboard.tsx`.
|
||||
|
||||
## Quality Gates (MANDATORY)
|
||||
|
||||
Before marking any task as complete, you MUST:
|
||||
|
||||
1. **Type check** — Run `npx tsc --noEmit` (frontend) or `python -m ruff check .` (backend) on modified files
|
||||
2. **No Unicode escapes** — Verify that NO `\uXXXX` sequences were introduced; if found, revert and fix
|
||||
3. **Dual-theme CSS** — For any UI change, confirm BOTH dark and light styles. Most components need `ScanTerminalLightTheme.module.css` updated; monitoring components (`MonitorPanel.module.css`) contain their own `.scan-terminal.light` blocks inline.
|
||||
4. **No new hardcoded palette colors** — Use `var(--color-*)` token references instead of `#4DA3FF` / `#E6EDF3` / `#9FB2C7` / `#6B7A90` hex values
|
||||
5. **Show the diff** — Output `git diff --stat` and test results before declaring success
|
||||
|
||||
If any gate fails, fix it BEFORE reporting success.
|
||||
- No `\uXXXX` escapes — write characters directly in UTF-8
|
||||
- Use `var(--color-*)` CSS tokens, not hardcoded hex
|
||||
- Minimum font size: 10px (`text-[10px]`)
|
||||
- Avoid `!important` except Leaflet map overrides
|
||||
- Remove dead code immediately when features are removed
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
function normalizeCityDetailPayload(data: unknown) {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
const payload = data as Record<string, any>;
|
||||
|
||||
// Backend v2 nests hourly under timeseries; chart expects it at top level.
|
||||
if (!payload.hourly && payload.timeseries?.hourly) {
|
||||
payload.hourly = payload.timeseries.hourly;
|
||||
}
|
||||
|
||||
if (!payload.market_scan && payload.market_scan_payload) {
|
||||
return {
|
||||
...payload,
|
||||
market_scan: payload.market_scan_payload,
|
||||
};
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
@@ -36,12 +62,32 @@ export async function GET(
|
||||
}
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
|
||||
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: cachePolicy.responseCacheControl,
|
||||
fetchCache:
|
||||
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
|
||||
publicMessage: "Failed to fetch city detail aggregate",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
url,
|
||||
});
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
headers: auth.headers,
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = normalizeCityDetailPayload(await res.json());
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
cachePolicy.responseCacheControl,
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch city detail aggregate",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Table2,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { getInitialLocaleFromNavigator } from "@/lib/i18n";
|
||||
import { isBrowserLocalFullAccess } from "@/lib/local-dev-access";
|
||||
@@ -44,7 +44,6 @@ import { ScanTerminalLoadingScreen } from "@/components/dashboard/scan-terminal/
|
||||
import { scanRootClass } from "@/components/dashboard/scan-root-styles";
|
||||
import { useRelativeTime } from "@/hooks/useRelativeTime";
|
||||
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
||||
import { GroupedMarketTable } from "@/components/dashboard/scan-terminal/GroupedMarketTable";
|
||||
import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard";
|
||||
import { LiveTemperatureThresholdChart } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||
import { MarketOverviewView } from "@/components/dashboard/scan-terminal/MarketOverviewView";
|
||||
@@ -862,7 +861,7 @@ function PolyWeatherTerminal({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0">
|
||||
<LiveTemperatureThresholdChart isEn={isEn} row={selectedRow} />
|
||||
<LiveTemperatureThresholdChart isEn={isEn} row={selectedRow} allRows={filteredRegionRows} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -1000,6 +999,9 @@ function ScanTerminalScreen() {
|
||||
() => filteredRows.find((row) => row.id === selectedId) || filteredRows[0] || null,
|
||||
[filteredRows, selectedId],
|
||||
);
|
||||
const handleSelectRow = useCallback((row: ScanOpportunityRow) => {
|
||||
setSelectedId(row.id);
|
||||
}, []);
|
||||
const generatedText = useRelativeTime(terminalData?.generated_at ?? null);
|
||||
|
||||
if (!hydrated || (proAccess.loading && !canUseLocalFullAccess)) {
|
||||
@@ -1032,7 +1034,7 @@ function ScanTerminalScreen() {
|
||||
refreshing={scanLoading}
|
||||
rows={filteredRows}
|
||||
selectedRow={selectedRow}
|
||||
setSelectedRow={(row) => setSelectedId(row.id)}
|
||||
setSelectedRow={handleSelectRow}
|
||||
toggleLocale={toggleLocale}
|
||||
userLocalTime={userLocalTime}
|
||||
searchQuery={searchQuery}
|
||||
|
||||
@@ -14,11 +14,117 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import type { AmosData, AirportCurrentConditions, CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
|
||||
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
||||
import { rowName, temp } from "@/components/dashboard/scan-terminal/utils";
|
||||
|
||||
const SETTLEMENT_RUNWAY_PAIRS: Record<string, Array<[string, string]>> = {
|
||||
shanghai: [["17L", "35R"]],
|
||||
beijing: [["01", "19"]],
|
||||
guangzhou: [["02L", "20R"]],
|
||||
chengdu: [["02L", "20R"]],
|
||||
chongqing: [["02L", "20R"]],
|
||||
wuhan: [["04", "22"]],
|
||||
seoul: [["15R", "33L"]],
|
||||
};
|
||||
|
||||
function normalizeRunwayLabel(value?: string | null) {
|
||||
return String(value || "").trim().toUpperCase().replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
function normalizeCityKey(value?: string | null) {
|
||||
return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, "");
|
||||
}
|
||||
|
||||
function pairKey(pair: [string, string]) {
|
||||
return pair.map(normalizeRunwayLabel).sort().join("/");
|
||||
}
|
||||
|
||||
function buildRunwayPlates(
|
||||
amos: AmosData | null | undefined,
|
||||
row: ScanOpportunityRow | null,
|
||||
settlementObs?: Array<{ ts: number; value: number }>,
|
||||
) {
|
||||
if (!amos) return [];
|
||||
const runwayObs = amos.runway_obs || {};
|
||||
const runwayPairs = runwayObs.runway_pairs || [];
|
||||
const runwayTemps = runwayObs.temperatures || [];
|
||||
const pointTemps = runwayObs.point_temperatures || [];
|
||||
|
||||
const cityKey = normalizeCityKey(row?.city);
|
||||
const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || [];
|
||||
const settlementKeys = new Set(settlementPairs.map(pairKey));
|
||||
|
||||
const list: Array<{
|
||||
rwy: string;
|
||||
isSettlement: boolean;
|
||||
tdzTemp: number | null;
|
||||
midTemp: number | null;
|
||||
endTemp: number | null;
|
||||
maxTemp: number | null;
|
||||
dailyHigh: number | null;
|
||||
trend_15m: number | null;
|
||||
}> = [];
|
||||
|
||||
runwayPairs.forEach((rawPair: any, index: number) => {
|
||||
const pair = rawPair as [string, string];
|
||||
if (!Array.isArray(pair) || pair.length < 2) return;
|
||||
const isSettlement = settlementKeys.has(pairKey(pair));
|
||||
|
||||
const tdz = validNumber(pointTemps[index]?.tdz_temp);
|
||||
const mid = validNumber(pointTemps[index]?.mid_temp);
|
||||
const end = validNumber(pointTemps[index]?.end_temp);
|
||||
|
||||
const historyVals = Array.isArray(runwayTemps[index])
|
||||
? (runwayTemps[index] as Array<number | null>).map(validNumber).filter((v): v is number => v !== null)
|
||||
: [];
|
||||
|
||||
const tdzVal = tdz !== null ? [tdz] : [];
|
||||
const midVal = mid !== null ? [mid] : [];
|
||||
const endVal = end !== null ? [end] : [];
|
||||
const allVals = [...historyVals, ...tdzVal, ...midVal, ...endVal];
|
||||
|
||||
const maxTemp = allVals.length ? Math.max(...allVals) : null;
|
||||
const dailyHigh = historyVals.length ? Math.max(...historyVals) : maxTemp;
|
||||
|
||||
// Calculate 15-minute trend
|
||||
const latest = historyVals.length > 0 ? historyVals[historyVals.length - 1] : (tdz ?? mid ?? end ?? null);
|
||||
const val15 = historyVals.length > 15 ? historyVals[historyVals.length - 16] : (historyVals.length > 0 ? historyVals[0] : null);
|
||||
let trend_15m = (latest !== null && val15 !== null) ? latest - val15 : null;
|
||||
|
||||
if (isSettlement && settlementObs && settlementObs.length >= 2) {
|
||||
const latestObs = settlementObs[settlementObs.length - 1];
|
||||
const targetTs = latestObs.ts - 15 * 60 * 1000;
|
||||
let closestPoint = settlementObs[0];
|
||||
let minDiff = Math.abs(closestPoint.ts - targetTs);
|
||||
for (let i = 1; i < settlementObs.length; i++) {
|
||||
const diff = Math.abs(settlementObs[i].ts - targetTs);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closestPoint = settlementObs[i];
|
||||
}
|
||||
}
|
||||
if (Math.abs(closestPoint.ts - targetTs) < 5 * 60 * 1000) {
|
||||
trend_15m = latestObs.value - closestPoint.value;
|
||||
}
|
||||
}
|
||||
|
||||
list.push({
|
||||
rwy: `${normalizeRunwayLabel(pair[0])}/${normalizeRunwayLabel(pair[1])}`,
|
||||
isSettlement,
|
||||
tdzTemp: tdz,
|
||||
midTemp: mid,
|
||||
endTemp: end,
|
||||
maxTemp,
|
||||
dailyHigh,
|
||||
trend_15m,
|
||||
});
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
type ObsPoint = { time?: string | null; temp?: number | null };
|
||||
|
||||
type EvidenceSeries = {
|
||||
@@ -41,39 +147,80 @@ function validNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function toTimestamp(value?: string | null): number | null {
|
||||
const raw = String(value || "").trim();
|
||||
function getCityLocalUtcTimestamp(
|
||||
value: string | number | null | undefined,
|
||||
tzOffsetSeconds: number,
|
||||
referenceLocalDate?: string | null
|
||||
): number | null {
|
||||
if (value == null) return null;
|
||||
|
||||
if (typeof value === "number") {
|
||||
const d = new Date(value + tzOffsetSeconds * 1000);
|
||||
return Date.UTC(
|
||||
d.getUTCFullYear(),
|
||||
d.getUTCMonth(),
|
||||
d.getUTCDate(),
|
||||
d.getUTCHours(),
|
||||
d.getUTCMinutes()
|
||||
);
|
||||
}
|
||||
|
||||
const raw = String(value).trim();
|
||||
if (!raw) return null;
|
||||
const d = new Date(raw);
|
||||
if (!Number.isNaN(d.getTime())) return d.getTime();
|
||||
// HH:MM or HH:MM:SS — treat as today, but handle cross-midnight:
|
||||
// if parsed time is >2h ahead of now, assume yesterday
|
||||
|
||||
if (raw.includes("T") || raw.includes("Z") || raw.includes("-")) {
|
||||
const d = new Date(raw);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
const localMs = d.getTime() + tzOffsetSeconds * 1000;
|
||||
const localDate = new Date(localMs);
|
||||
return Date.UTC(
|
||||
localDate.getUTCFullYear(),
|
||||
localDate.getUTCMonth(),
|
||||
localDate.getUTCDate(),
|
||||
localDate.getUTCHours(),
|
||||
localDate.getUTCMinutes()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const m = raw.match(/(\d{1,2}):(\d{2})/);
|
||||
if (m) {
|
||||
const now = new Date();
|
||||
const h = +m[1], min = +m[2];
|
||||
const candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, min);
|
||||
if (candidate.getTime() - now.getTime() > 2 * 60 * 60 * 1000) {
|
||||
candidate.setDate(candidate.getDate() - 1);
|
||||
const h = +m[1];
|
||||
const min = +m[2];
|
||||
|
||||
let year = new Date().getUTCFullYear();
|
||||
let month = new Date().getUTCMonth();
|
||||
let date = new Date().getUTCDate();
|
||||
|
||||
if (referenceLocalDate) {
|
||||
const dateParts = referenceLocalDate.split("-");
|
||||
if (dateParts.length === 3) {
|
||||
year = parseInt(dateParts[0]);
|
||||
month = parseInt(dateParts[1]) - 1;
|
||||
date = parseInt(dateParts[2]);
|
||||
}
|
||||
}
|
||||
return candidate.getTime();
|
||||
|
||||
return Date.UTC(year, month, date, h, min);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function normObs(points?: ObsPoint[] | null, limit = MAX_OBS_POINTS) {
|
||||
function normObs(points: ObsPoint[] | null | undefined, tzOffsetSeconds: number, limit = MAX_OBS_POINTS) {
|
||||
return (points || [])
|
||||
.filter((p) => validNumber(p.temp) !== null && toTimestamp(p.time) !== null)
|
||||
.slice(-limit)
|
||||
.filter((p) => validNumber(p.temp) !== null && p.time)
|
||||
.map((p) => ({
|
||||
ts: toTimestamp(p.time)!,
|
||||
ts: getCityLocalUtcTimestamp(p.time, tzOffsetSeconds)!,
|
||||
value: Number(p.temp),
|
||||
}));
|
||||
}))
|
||||
.filter((p) => p.ts !== null)
|
||||
.slice(-limit);
|
||||
}
|
||||
|
||||
function seriesStats(values: Array<number | null>) {
|
||||
@@ -91,6 +238,9 @@ type HourlyForecast = {
|
||||
times: string[];
|
||||
temps: Array<number | null>;
|
||||
modelCurves?: Record<string, Array<number | null>>;
|
||||
amos?: AmosData | null;
|
||||
airportCurrent?: AirportCurrentConditions | null;
|
||||
airportPrimary?: AirportCurrentConditions | null;
|
||||
} | null;
|
||||
|
||||
// ── Build aligned data rows for the sliding-window chart ────────────────
|
||||
@@ -99,8 +249,11 @@ function buildSlidingChartData(
|
||||
row: ScanOpportunityRow | null,
|
||||
hourly: HourlyForecast,
|
||||
) {
|
||||
const settlementObs = normObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs);
|
||||
const metarObs = normObs(row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs);
|
||||
const tzOffset = row?.tz_offset_seconds ?? 0;
|
||||
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
|
||||
|
||||
const settlementObs = normObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset);
|
||||
const metarObs = normObs(row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset);
|
||||
|
||||
// Collect all timestamps from observations + forecasts
|
||||
const allTimes = new Set<number>();
|
||||
@@ -115,7 +268,7 @@ function buildSlidingChartData(
|
||||
const forecastTimes: number[] = [];
|
||||
if (hourly?.times?.length && hourly?.temps?.length) {
|
||||
hourly.times.forEach((t, i) => {
|
||||
const ts = toTimestamp(t);
|
||||
const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr);
|
||||
if (ts !== null && i < hourly.temps.length) {
|
||||
allTimes.add(ts);
|
||||
forecastTimes.push(ts);
|
||||
@@ -143,9 +296,27 @@ function buildSlidingChartData(
|
||||
if (idx !== undefined) sVals[idx] = o.value;
|
||||
});
|
||||
if (sVals.some((v) => v !== null)) {
|
||||
const cityKey = String(row?.city || "").toLowerCase().trim();
|
||||
const runwaySensorCities = new Set([
|
||||
'beijing', 'shanghai', 'guangzhou', 'shenzhen', 'qingdao',
|
||||
'chengdu', 'chongqing', 'wuhan', // AMSC runway sensors
|
||||
'seoul', 'busan', // AMOS runway sensors
|
||||
]);
|
||||
const isHKO = cityKey === 'hong kong' || cityKey === 'lau fau shan' || cityKey.includes('hongkong') || cityKey.includes('laufau');
|
||||
const isTokyo = cityKey === 'tokyo';
|
||||
const isSingapore = cityKey === 'singapore';
|
||||
const isWeatherStation = !runwaySensorCities.has(cityKey)
|
||||
&& !isHKO && !isTokyo && !isSingapore;
|
||||
|
||||
const runwayHeaderLabel = isHKO ? '参考站点 (1分钟)'
|
||||
: isTokyo ? '机场气象站 (10分钟)'
|
||||
: isSingapore ? '航站楼温度'
|
||||
: isWeatherStation ? '气象站实测'
|
||||
: '跑道实测 (1分钟)';
|
||||
|
||||
series.push({
|
||||
key: "settlement",
|
||||
label: row?.metar_context?.station_label || row?.metar_context?.station || "Settlement",
|
||||
label: runwayHeaderLabel,
|
||||
source: row?.metar_context?.station || row?.airport || "Settlement",
|
||||
color: "#009688",
|
||||
featured: true,
|
||||
@@ -181,7 +352,7 @@ function buildSlidingChartData(
|
||||
);
|
||||
const debVals = na();
|
||||
hourly.times.forEach((t, i) => {
|
||||
const ts = toTimestamp(t);
|
||||
const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr);
|
||||
const idx = ts !== null ? tsToIdx.get(ts) : undefined;
|
||||
if (idx !== undefined && i < debPath.debTemps.length) {
|
||||
debVals[idx] = validNumber(debPath.debTemps[i]);
|
||||
@@ -207,7 +378,7 @@ function buildSlidingChartData(
|
||||
if (!modelTemps?.length) return;
|
||||
const vals = na();
|
||||
hourly.times.forEach((t, i) => {
|
||||
const ts = toTimestamp(t);
|
||||
const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr);
|
||||
const x = ts !== null ? tsToIdx.get(ts) : undefined;
|
||||
if (x !== undefined && i < modelTemps.length) vals[x] = validNumber(modelTemps[i]);
|
||||
});
|
||||
@@ -320,9 +491,11 @@ function buildChartDomain(
|
||||
export function LiveTemperatureThresholdChart({
|
||||
isEn,
|
||||
row,
|
||||
allRows = [],
|
||||
}: {
|
||||
isEn: boolean;
|
||||
row: ScanOpportunityRow | null;
|
||||
allRows?: ScanOpportunityRow[];
|
||||
}) {
|
||||
const [hourly, setHourly] = useState<HourlyForecast>(null);
|
||||
const city = String(row?.city || "").toLowerCase().trim();
|
||||
@@ -352,6 +525,9 @@ export function LiveTemperatureThresholdChart({
|
||||
times: hourlySource.times || [],
|
||||
temps: hourlySource.temps || [],
|
||||
modelCurves: (json.models_hourly ?? (json as any)?.timeseries?.models_hourly)?.curves || undefined,
|
||||
amos: json.amos || null,
|
||||
airportCurrent: json.airport_current || null,
|
||||
airportPrimary: json.airport_primary || null,
|
||||
};
|
||||
_hourlyCache.set(city, { ts: Date.now(), data });
|
||||
setHourly(data);
|
||||
@@ -362,15 +538,99 @@ export function LiveTemperatureThresholdChart({
|
||||
|
||||
const { data, series } = useMemo(() => buildSlidingChartData(row, hourly), [row, hourly]);
|
||||
const threshold = validNumber(row?.target_threshold) ?? validNumber(row?.target_value);
|
||||
const modelSummaryCards = useMemo(() => {
|
||||
const cards = buildModelSummaryCards(row);
|
||||
if (!hourly?.modelCurves) return cards;
|
||||
const curveKeys = new Set(Object.keys(hourly.modelCurves));
|
||||
return cards.filter((c) => !curveKeys.has(c.label));
|
||||
}, [row, hourly]);
|
||||
const tableRows = [...series, ...modelSummaryCards]
|
||||
.slice(0, 5)
|
||||
.map((item) => ({ ...item, ...seriesStats(item.values) }));
|
||||
|
||||
const tzOffset = row?.tz_offset_seconds ?? 0;
|
||||
const settlementObs = useMemo(() => {
|
||||
return normObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset);
|
||||
}, [row, tzOffset]);
|
||||
|
||||
const runwayPlates = useMemo(() => buildRunwayPlates(hourly?.amos, row, settlementObs), [hourly?.amos, row, settlementObs]);
|
||||
const settlementPlate = useMemo(() => runwayPlates.find((p) => p.isSettlement), [runwayPlates]);
|
||||
|
||||
const cityKey = String(row?.city || "").toLowerCase().trim();
|
||||
const runwaySensorCities = new Set([
|
||||
'beijing', 'shanghai', 'guangzhou', 'shenzhen', 'qingdao',
|
||||
'chengdu', 'chongqing', 'wuhan', // AMSC runway sensors
|
||||
'seoul', 'busan', // AMOS runway sensors
|
||||
]);
|
||||
const isHKO = cityKey === 'hong kong' || cityKey === 'lau fau shan' || cityKey.includes('hongkong') || cityKey.includes('laufau');
|
||||
const isTokyo = cityKey === 'tokyo';
|
||||
const isSingapore = cityKey === 'singapore';
|
||||
const isWeatherStation = !runwaySensorCities.has(cityKey)
|
||||
&& !isHKO && !isTokyo && !isSingapore;
|
||||
|
||||
const runwayHeaderLabel = isHKO ? '参考站点 (1分钟)'
|
||||
: isTokyo ? '机场气象站 (10分钟)'
|
||||
: isSingapore ? '航站楼温度'
|
||||
: isWeatherStation ? '气象站实测'
|
||||
: '跑道实测 (1分钟)';
|
||||
|
||||
const metarHeaderLabel = isHKO ? '天文台实测 (10分钟)'
|
||||
: 'METAR 结算 (30分钟)';
|
||||
|
||||
const runwayHighLabel = isHKO ? '参考站点'
|
||||
: isTokyo ? '机场气象站'
|
||||
: isSingapore ? '航站楼'
|
||||
: isWeatherStation ? '气象站'
|
||||
: '跑道实测';
|
||||
|
||||
const metarHighLabel = isHKO ? '天文台'
|
||||
: 'METAR 官方';
|
||||
|
||||
const currentRunwayTemp = validNumber(hourly?.amos?.temp_c) ?? validNumber(row?.current_temp) ?? settlementPlate?.maxTemp ?? null;
|
||||
const observedHighMetar = validNumber(row?.metar_context?.airport_max_so_far ?? row?.metar_context?.max_temp ?? row?.current_max_so_far) ?? null;
|
||||
const observedHighRunway = validNumber(row?.current_max_so_far) ?? settlementPlate?.maxTemp ?? currentRunwayTemp ?? null;
|
||||
const wundergroundDailyHigh = validNumber(hourly?.airportCurrent?.max_so_far ?? hourly?.airportPrimary?.max_so_far) ?? null;
|
||||
|
||||
const modelValues = Object.values(row?.model_cluster_sources || {})
|
||||
.map(validNumber)
|
||||
.filter((v): v is number => v !== null);
|
||||
const modelMin = modelValues.length ? Math.min(...modelValues) : (row?.cluster_core_low ?? null);
|
||||
const modelMax = modelValues.length ? Math.max(...modelValues) : (row?.cluster_core_high ?? null);
|
||||
const debVal = validNumber(row?.deb_prediction) ?? null;
|
||||
|
||||
const spread = (modelMax !== null && modelMin !== null) ? modelMax - modelMin : null;
|
||||
const spreadLabel = spread === null ? "" : (spread <= 2.0 ? "低分歧" : (spread <= 4.0 ? "中等分歧" : "高分歧"));
|
||||
const spreadLabelEn = spread === null ? "" : (spread <= 2.0 ? "Low" : (spread <= 4.0 ? "Medium" : "High"));
|
||||
|
||||
const formattedUpdateTime = useMemo(() => {
|
||||
if (row?.local_date && row?.local_time) {
|
||||
return `${row.local_date} ${row.local_time.slice(0, 8)}`;
|
||||
}
|
||||
const d = new Date();
|
||||
return d.toISOString().replace('T', ' ').slice(0, 19);
|
||||
}, [row]);
|
||||
|
||||
const cityThresholds = useMemo(() => {
|
||||
if (!row || !allRows || !allRows.length) return [];
|
||||
const cityKey = String(row.city || "").toLowerCase().trim();
|
||||
const sameCityRows = allRows.filter(
|
||||
(r) => String(r.city || "").toLowerCase().trim() === cityKey
|
||||
);
|
||||
|
||||
const seen = new Set<number>();
|
||||
const list: { threshold: number; label: string; isBreached: boolean; kind: "gte" | "lte" }[] = [];
|
||||
sameCityRows.forEach((r) => {
|
||||
const t = Number(r.target_threshold ?? r.target_value ?? r.target_lower ?? r.target_upper);
|
||||
if (!Number.isFinite(t) || seen.has(t)) return;
|
||||
seen.add(t);
|
||||
|
||||
const maxTemp = Number(r.current_max_so_far ?? r.current_temp ?? 0);
|
||||
const q = String(r.market_question || r.target_label || "").toLowerCase();
|
||||
const kind: "gte" | "lte" = q.includes("below") || q.includes("under") || q.includes("lte") ? "lte" : "gte";
|
||||
const isBreached = kind === "lte" ? maxTemp > t : maxTemp >= t;
|
||||
|
||||
list.push({
|
||||
threshold: t,
|
||||
label: r.target_label || `${t}°C`,
|
||||
isBreached,
|
||||
kind,
|
||||
});
|
||||
});
|
||||
|
||||
return list.sort((a, b) => a.threshold - b.threshold);
|
||||
}, [row, allRows]);
|
||||
|
||||
const marketTicks = useMemo(() => buildMarketTemperatureOptions(row), [row]);
|
||||
const chartDomain = useMemo(() => buildChartDomain(marketTicks, series), [marketTicks, series]);
|
||||
|
||||
@@ -378,49 +638,129 @@ export function LiveTemperatureThresholdChart({
|
||||
<Panel title={isEn ? "Live Temperature Trend & Option Threshold Lines" : "实时气温走势与期权阈值线"}>
|
||||
<div className="flex h-full min-h-[420px] flex-col">
|
||||
{/* Stats bar */}
|
||||
<div className="shrink-0 border-b border-slate-200 bg-white px-3 py-2">
|
||||
<div className="mb-2 flex items-end justify-between gap-3 text-[10px]">
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-mono font-black text-teal-700">
|
||||
{isEn ? "Settlement live" : "跑道实测"} {temp(validNumber(row?.current_temp))}
|
||||
<div className="shrink-0 border-b border-slate-200 bg-white px-4 py-3">
|
||||
{/* Top Row: Large temperatures */}
|
||||
<div className="flex justify-between items-center gap-6 mb-3">
|
||||
<div className="flex items-center gap-12">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`}
|
||||
</span>
|
||||
<span className="text-2xl font-bold font-mono text-[#009688] mt-1">
|
||||
{temp(currentRunwayTemp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono font-black text-blue-600">
|
||||
METAR {temp(validNumber(row?.metar_context?.airport_current_temp ?? row?.metar_context?.last_temp))}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{isEn ? "METAR Settlement (30m) · Daily High" : `${metarHeaderLabel} · 当日最高`}
|
||||
</span>
|
||||
<span className="text-2xl font-bold font-mono text-blue-600 mt-1">
|
||||
{temp(observedHighMetar)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right font-mono font-black text-slate-800">
|
||||
{isEn ? "Threshold" : "当日阈值"} {temp(threshold)}
|
||||
|
||||
<div className="hidden sm:flex flex-col items-end text-right">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-semibold">
|
||||
{isEn ? "Daily Peak" : "当日最高气温"}
|
||||
</span>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs font-mono text-slate-600">
|
||||
<span>{isEn ? "Runway" : runwayHighLabel}: <strong className="text-[#009688]">{temp(observedHighRunway)}</strong></span>
|
||||
<span>|</span>
|
||||
<span>{isEn ? "METAR" : metarHighLabel}: <strong className="text-blue-600">{temp(observedHighMetar)}</strong></span>
|
||||
{wundergroundDailyHigh !== null && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<span>WU: <strong className="text-purple-600">{temp(wundergroundDailyHigh)}</strong></span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-1.5 text-[10px]">
|
||||
{tableRows.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={clsx(
|
||||
"rounded border px-2 py-1.5",
|
||||
item.featured ? "border-teal-200 bg-teal-50" : "border-slate-200 bg-slate-50",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-1.5 w-4 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="truncate font-black text-slate-700">{item.label}</span>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[10px] text-slate-600">
|
||||
{item.key.startsWith("model_summary_") ? (
|
||||
<span>{temp(item.latest)}</span>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<span>now: {temp(item.latest)}</span>
|
||||
<span>max: {temp(item.high)}</span>
|
||||
<span>Δ15: {item.delta15 === null ? "--" : `${item.delta15 >= 0 ? "+" : ""}${item.delta15.toFixed(1)}°`}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Bottom Row: Model Range Panel */}
|
||||
<div className="grid grid-cols-4 gap-4 border-t border-slate-100 pt-3 text-xs font-mono text-slate-700 bg-slate-50/50 -mx-4 px-4 rounded-b-md">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-semibold">
|
||||
{isEn ? "Model Range" : "模型区间"}
|
||||
</span>
|
||||
<strong className="text-slate-800 font-bold">
|
||||
{modelMin !== null && modelMax !== null ? `${temp(modelMin)} - ${temp(modelMax)}` : "--"}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-semibold">
|
||||
DEB
|
||||
</span>
|
||||
<strong className="text-blue-600 font-bold">
|
||||
{temp(debVal)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-semibold">
|
||||
{isEn ? "Spread" : "分歧"}
|
||||
</span>
|
||||
<strong className={clsx("font-bold", spreadLabel === "高分歧" ? "text-amber-600" : "text-slate-600")}>
|
||||
{spread !== null ? `${spread.toFixed(1)}°C` : "--"}
|
||||
{spreadLabel && ` · ${isEn ? spreadLabelEn : spreadLabel}`}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-semibold">
|
||||
{isEn ? "Updated" : "更新时间"}
|
||||
</span>
|
||||
<strong className="text-slate-800 font-bold">
|
||||
{formattedUpdateTime}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runway observations */}
|
||||
{runwayPlates.length > 0 && (
|
||||
<div className="shrink-0 border-b border-slate-200 bg-[#f8fafc] px-3 py-2">
|
||||
<div className="flex items-center justify-between text-[11px] font-black text-slate-700 mb-1.5 uppercase">
|
||||
<span>{isEn ? "Runway Observations" : "跑道观测"}</span>
|
||||
{runwayPlates.some((p) => p.trend_15m !== null && p.trend_15m > 0 && !p.isSettlement) && (
|
||||
<span className="text-[10px] bg-amber-50 text-amber-700 border border-amber-200 px-1.5 py-0.5 rounded font-sans">
|
||||
{isEn ? "Non-settlement Runway Warming Alert" : "非结算跑道升温提醒"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
{runwayPlates.map((plate) => (
|
||||
<div
|
||||
key={plate.rwy}
|
||||
className={clsx(
|
||||
"grid grid-cols-7 gap-2 items-center border rounded px-2.5 py-1 text-[11px] font-mono",
|
||||
plate.isSettlement
|
||||
? "border-emerald-200 bg-emerald-50/50 text-emerald-950 font-bold"
|
||||
: "border-slate-200 bg-white text-slate-600"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 font-sans font-bold text-slate-800">
|
||||
{plate.isSettlement && <span className="h-1.5 w-1.5 rounded-full bg-emerald-600 animate-pulse" />}
|
||||
<span>{plate.rwy}</span>
|
||||
{plate.isSettlement && (
|
||||
<span className="text-[9px] bg-teal-200 text-teal-800 px-1 rounded font-normal">
|
||||
{isEn ? "Settlement" : "结算"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>TDZ: <strong>{plate.tdzTemp !== null ? `${plate.tdzTemp.toFixed(1)}°C` : "--"}</strong></div>
|
||||
<div>MID: <strong>{plate.midTemp !== null ? `${plate.midTemp.toFixed(1)}°C` : "--"}</strong></div>
|
||||
<div>END: <strong>{plate.endTemp !== null ? `${plate.endTemp.toFixed(1)}°C` : "--"}</strong></div>
|
||||
<div>max: <strong>{plate.maxTemp !== null ? `${plate.maxTemp.toFixed(1)}°C` : "--"}</strong></div>
|
||||
<div>high: <strong>{plate.dailyHigh !== null ? `${plate.dailyHigh.toFixed(1)}°C` : "--"}</strong></div>
|
||||
<div className={clsx(plate.trend_15m !== null && plate.trend_15m > 0 ? "text-orange-600 font-bold" : "text-slate-500")}>
|
||||
15m: <strong>{plate.trend_15m !== null ? `${plate.trend_15m >= 0 ? "+" : ""}${plate.trend_15m.toFixed(1)}°C` : "--"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
<div className="relative min-h-0 flex-1 p-2">
|
||||
<div className="absolute left-3 top-3 z-10 rounded border border-slate-200 bg-white px-2 py-1 text-[10px] font-black text-slate-800 shadow-sm">
|
||||
@@ -449,15 +789,28 @@ export function LiveTemperatureThresholdChart({
|
||||
domain={chartDomain}
|
||||
ticks={marketTicks ?? undefined}
|
||||
/>
|
||||
{threshold !== null && (
|
||||
<ReferenceLine
|
||||
y={threshold}
|
||||
stroke="#f97316"
|
||||
strokeDasharray="4 3"
|
||||
strokeWidth={2}
|
||||
label={{ value: `${threshold.toFixed(1)}°`, fill: "#f97316", fontSize: 10, position: "left" }}
|
||||
/>
|
||||
)}
|
||||
{cityThresholds.map((t, idx) => {
|
||||
const isSelected = row && (Number(row.target_threshold ?? row.target_value) === t.threshold);
|
||||
const labelText = isEn
|
||||
? `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}° [${t.isBreached ? "Excluded" : "Active"}]`
|
||||
: `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}° [${t.isBreached ? "已排除" : "活跃"}]`;
|
||||
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={idx}
|
||||
y={t.threshold}
|
||||
stroke={isSelected ? "#3b82f6" : t.isBreached ? "#ef4444" : "#f97316"}
|
||||
strokeDasharray={isSelected ? undefined : "4 4"}
|
||||
strokeWidth={isSelected ? 2 : 1}
|
||||
label={{
|
||||
value: labelText,
|
||||
fill: isSelected ? "#3b82f6" : t.isBreached ? "#ef4444" : "#f97316",
|
||||
fontSize: 9,
|
||||
position: isSelected ? "left" : "insideBottomRight",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
border: "1px solid #cbd5e1",
|
||||
|
||||
@@ -145,7 +145,18 @@ async def get_city_detail_aggregate_payload(
|
||||
) -> Dict[str, Any]:
|
||||
legacy_routes._assert_entitlement(request)
|
||||
city = legacy_routes._normalize_city_or_404(name)
|
||||
data = await run_in_threadpool(legacy_routes._analyze, city, force_refresh, True)
|
||||
if force_refresh:
|
||||
data = await run_in_threadpool(legacy_routes._refresh_city_full_cache, city, True)
|
||||
else:
|
||||
cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "full", city)
|
||||
if cached_entry:
|
||||
if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_FULL_CACHE_TTL_SEC):
|
||||
data = await run_in_threadpool(legacy_routes._refresh_city_full_cache, city, False)
|
||||
else:
|
||||
data = cached_entry.get("payload") or {}
|
||||
else:
|
||||
data = await run_in_threadpool(legacy_routes._refresh_city_full_cache, city, False)
|
||||
|
||||
return await run_in_threadpool(
|
||||
legacy_routes._build_city_detail_payload,
|
||||
data,
|
||||
|
||||
Reference in New Issue
Block a user