Compare commits

...

204 Commits

Author SHA1 Message Date
2569718930@qq.com 9823e3961f Add localized meteorology text and filter implausible METAR temps 2026-04-16 17:34:39 +08:00
2569718930@qq.com 2598c5ac98 Disable caching for city list updates 2026-04-16 17:18:19 +08:00
2569718930@qq.com e2cb0cfe5e Add professional intraday meteorology analysis 2026-04-16 17:13:53 +08:00
2569718930@qq.com fd2b870d6a Add Manila Karachi and Masroor weather sources 2026-04-16 16:43:37 +08:00
2569718930@qq.com 46b0e63ffd Prefer METAR over NMC for city observation display 2026-04-16 15:53:00 +08:00
2569718930@qq.com 0eee5c8948 Stabilize METAR fetching with fallback requests 2026-04-16 15:43:47 +08:00
2569718930@qq.com 25424700ce Restore observed temperatures on map and trend charts 2026-04-16 15:29:26 +08:00
2569718930@qq.com 767f7ed6bf Show observed temp and format observation updates 2026-04-16 15:19:31 +08:00
2569718930@qq.com c0e5307893 Add cached dashboard prewarm hints for priority cities 2026-04-16 15:05:14 +08:00
2569718930@qq.com b635070106 Fix history fallback handling and auth status probe behavior 2026-04-16 13:26:53 +08:00
2569718930@qq.com 2ead56f59d Simplify detail panel loading state 2026-04-16 13:21:29 +08:00
2569718930@qq.com 611e2d9009 Expand calibration samples and extend training retention 2026-04-16 01:05:43 +08:00
2569718930@qq.com 53d2ae4c10 Retire dual storage mode and default runtime state to sqlite 2026-04-16 00:52:55 +08:00
2569718930@qq.com 4bfd2534bb Allow free users to load city detail panels 2026-04-16 00:30:12 +08:00
2569718930@qq.com df82234b66 Remove intraday structure signals from analysis modal 2026-04-16 00:24:30 +08:00
2569718930@qq.com fcdece7ab4 Fix dashboard observation legend ordering 2026-04-16 00:16:33 +08:00
2569718930@qq.com 2e81894fd1 Remove detail summary card from the dashboard panel 2026-04-16 00:12:00 +08:00
2569718930@qq.com 26c4162f50 Simplify detail panel by removing snapshot copy and scenery 2026-04-16 00:08:29 +08:00
2569718930@qq.com 6249a00382 Refine dashboard detail panel layout 2026-04-15 23:51:54 +08:00
2569718930@qq.com da3acbbb6e feat: initialize project dashboard layout and global design system with Tailwind CSS 2026-04-15 23:08:56 +08:00
2569718930@qq.com 62298eaf38 Harden auth profile fallback for slow backend responses 2026-04-15 22:40:01 +08:00
2569718930@qq.com c4b45be757 Remove Polymarket price integration from dashboard 2026-04-15 16:30:13 +08:00
2569718930@qq.com c55b83a134 Remove dashboard prewarm requests 2026-04-15 15:51:08 +08:00
2569718930@qq.com aa6ed30987 Add Guangzhou city support and prewarm defaults 2026-04-15 15:14:14 +08:00
2569718930@qq.com c66b9f898d Refresh Busan snapshots and prefer populated nearby stations 2026-04-15 13:57:27 +08:00
2569718930@qq.com 2e2c55d77a Add market detail depth caching for dashboard data 2026-04-14 12:03:37 +08:00
2569718930@qq.com baaccc6636 feat: implement multi-source weather data collection system and dashboard frontend with integrated analysis services. 2026-04-13 18:42:27 +08:00
2569718930@qq.com 41cfb65b92 Show queued subscription access and fix forecast time plotting 2026-04-13 16:35:22 +08:00
2569718930@qq.com 57acbc41c9 Handle delegated payment confirms and recover failed intents 2026-04-13 13:37:39 +08:00
2569718930@qq.com 1d54f44189 Speed up intraday modal loading and add sync status 2026-04-13 11:19:24 +08:00
2569718930@qq.com f0940b9c8b Speed up JMA nearby refresh and link Tokyo to 10-minute data 2026-04-13 11:02:45 +08:00
2569718930@qq.com f7f6ba39f7 Refresh nearby stations after map idle 2026-04-12 21:03:16 +08:00
2569718930@qq.com a27ae564fb Load nearby stations before opening intraday analysis 2026-04-12 20:49:51 +08:00
2569718930@qq.com a0adf05d1e Delay background preload until map interaction stops 2026-04-12 10:44:03 +08:00
2569718930@qq.com 627bb8aa06 Prioritize homepage summary preloading for Asia and Europe 2026-04-12 10:42:00 +08:00
2569718930@qq.com 77b4d7b341 feat: implement METAR data collection service and dashboard infrastructure 2026-04-11 20:11:01 +08:00
2569718930@qq.com 69168d2fdf Add Lagos, Cape Town, and Jeddah to city registry 2026-04-11 17:18:53 +08:00
2569718930@qq.com faba38578e Handle percent-formatted precipitation values in forecast modal 2026-04-11 11:00:01 +08:00
2569718930@qq.com faa5bc94a7 Highlight precipitation windows in front trend signals 2026-04-11 10:16:00 +08:00
2569718930@qq.com 95e1fd50d2 Add Moscow Russia station web nearby provider with stale filtering 2026-04-10 18:49:50 +08:00
2569718930@qq.com 373e7cfec6 Shift default prewarm cities from US to Asia and Europe 2026-04-10 11:22:11 +08:00
2569718930@qq.com 15022c77c0 Gate checkout overlay by subscription recovery eligibility 2026-04-10 09:00:37 +08:00
2569718930@qq.com 150c793b35 Avoid blocking city details while pro access is loading 2026-04-10 08:16:15 +08:00
2569718930@qq.com a5eff0031c Update docs for v1.5.3 release 2026-04-10 08:04:25 +08:00
2569718930@qq.com 27d4fc7c2b Remove unused Lock import from KMA station source 2026-04-10 07:41:09 +08:00
2569718930@qq.com fef37f6b0f Add KMA nearby weather support for Seoul and Busan 2026-04-09 20:19:07 +08:00
2569718930@qq.com 1fa5645c0d feat: add DetailPanel component for displaying city-specific weather data and charts 2026-04-09 14:24:03 +08:00
2569718930@qq.com 24ef8ee8be Add loading indicators for city detail sync 2026-04-09 08:29:02 +08:00
2569718930@qq.com 799abd71b0 Show syncing state while loading city snapshot 2026-04-08 20:22:32 +08:00
2569718930@qq.com 4316f42a68 Prevent dashboard loading overlay during city detail fetch 2026-04-08 20:09:02 +08:00
2569718930@qq.com d9faff1bc3 Fix dashboard auth and map marker cache updates 2026-04-08 18:52:59 +08:00
2569718930@qq.com f108c9f7df Restore guest city summaries and refetch missing Pro details 2026-04-08 18:25:25 +08:00
2569718930@qq.com 951eb2f261 Update lockfile for Solana kit dependencies 2026-04-08 17:39:44 +08:00
2569718930@qq.com 28ee268b35 Remove unused threading import from JMA AMEDAS sources 2026-04-08 17:24:43 +08:00
2569718930@qq.com 6c4f9f8203 Gate dashboard detail caches behind pro access 2026-04-08 17:17:51 +08:00
2569718930@qq.com 6f80d31852 Add JMA Haneda temps and fix stale detail loading 2026-04-08 16:20:02 +08:00
2569718930@qq.com 7b1f34db27 Refresh sparse model detail caches in forecast views 2026-04-08 15:23:34 +08:00
2569718930@qq.com 0af5a84449 Keep dashboard panel visible during city detail loading 2026-04-08 12:55:12 +08:00
2569718930@qq.com f0904dbcc5 Add Groq commentary config and simplify forecast modal 2026-04-08 11:41:34 +08:00
2569718930@qq.com 1d937728ee Refine future forecast modal trade signals and card styling 2026-04-08 11:16:51 +08:00
2569718930@qq.com 13f0713a98 Remove risk profile card from today analysis modal 2026-04-08 10:54:53 +08:00
2569718930@qq.com b16bfae991 Add intraday pace card to today's analysis 2026-04-08 10:33:58 +08:00
2569718930@qq.com cdccd4a21a Persist prewarm worker runtime to shared state 2026-04-08 07:13:54 +08:00
2569718930@qq.com c3da29c09c Add dashboard prewarm worker and cache visibility 2026-04-08 06:53:49 +08:00
2569718930@qq.com 0bb3b573e1 Filter invalid NMC wind placeholders from map labels 2026-04-07 13:02:45 +08:00
2569718930@qq.com 22621f5d9c Fix exact email grants and clarify cloud and wind labels 2026-04-07 10:17:17 +08:00
2569718930@qq.com eab6ec7cff Handle future subscriptions and trial overlap correctly 2026-04-07 09:54:21 +08:00
2569718930@qq.com 420aa32a39 Remove manual grant detail from subscription audit log 2026-04-07 09:39:33 +08:00
2569718930@qq.com 1b5b38e99d feat: add script to manually grant subscriptions by email 2026-04-07 09:35:33 +08:00
2569718930@qq.com 3f13257127 Show METAR labels for Wunderground realtime observations 2026-04-06 21:04:16 +08:00
2569718930@qq.com fbd1e4ab16 Persist recent payment recovery state across account sessions 2026-04-06 20:40:26 +08:00
2569718930@qq.com 7fe16bd584 Update frontend lockfile for proxy-from-env 2.1.0 2026-04-06 14:23:37 +08:00
2569718930@qq.com 08d7308486 Gate analytics and cache public API requests 2026-04-06 13:58:11 +08:00
2569718930@qq.com 9be12ad1d7 Clarify NMC nearby station labels on the map 2026-04-06 12:54:22 +08:00
2569718930@qq.com 26674cf2b7 Clarify NMC labels as regional observations 2026-04-06 12:47:52 +08:00
2569718930@qq.com 0f5c658ba8 Refine map marker offsets and remove duplicate airport sources 2026-04-06 11:32:03 +08:00
2569718930@qq.com 2e44b40b86 Prefer station labels in nearby map markers 2026-04-06 11:19:22 +08:00
2569718930@qq.com 5117a08057 Update docs and clarify OBS map labels 2026-04-06 07:45:42 +08:00
2569718930@qq.com 960a06672e Separate overlapping nearby observation markers on the map 2026-04-06 07:37:25 +08:00
2569718930@qq.com cc08839b51 Update Shenzhen NMC source URL and station code 2026-04-06 07:25:24 +08:00
2569718930@qq.com bdc300bdd2 Expose station network coverage and settlement station details 2026-04-06 07:21:59 +08:00
2569718930@qq.com e4a5c4c8d5 Remove duplicate TruthRecordRepository import 2026-04-06 05:43:08 +08:00
2569718930@qq.com f4335833b4 Promote Pro trial and rename settlement source to station 2026-04-06 05:38:41 +08:00
2569718930@qq.com e2c43351bd Make city history read from SQLite truth and feature records 2026-04-05 10:57:51 +08:00
2569718930@qq.com 92a1a7de12 Remove Wunderground settlement reference from extension charts 2026-04-05 10:26:28 +08:00
2569718930@qq.com b93a917f88 Remove Wunderground settlement reference from chart 2026-04-05 07:24:09 +08:00
2569718930@qq.com a5b5711863 Remove AI analysis and rank cities by recent DEB performance 2026-04-05 07:13:00 +08:00
2569718930@qq.com 343c5c9c2f Add new Wunderground-backed cities and source links 2026-04-05 01:23:10 +08:00
2569718930@qq.com 15a16f59fe Align browser extension with current market data sources 2026-04-03 02:21:23 +08:00
2569718930@qq.com 7f9e774548 Refine ops dashboards and align browser extension data views 2026-04-03 01:53:28 +08:00
2569718930@qq.com 3e265d2764 Refactor weather app logic and simplify related components 2026-04-03 01:47:03 +08:00
2569718930@qq.com e32eff6e31 Expand ops admin views for truth history and training data 2026-04-03 01:38:53 +08:00
2569718930@qq.com 3f82fd2855 Add ops truth history admin view and navigation 2026-04-03 01:32:45 +08:00
2569718930@qq.com bbdba2540c Add admin truth history dashboard and training data ops views 2026-04-03 01:27:54 +08:00
2569718930@qq.com 781c247952 Add ops dashboards for training data and model coverage 2026-04-03 00:57:19 +08:00
2569718930@qq.com 37cd8b8166 Update deep research report for monitoring progress 2026-04-02 23:35:13 +08:00
2569718930@qq.com 90791b6070 Refactor monitoring relay alert dispatch 2026-04-02 23:30:52 +08:00
2569718930@qq.com 95c0c452f1 Finalize SQLite defaults and refresh calibration artifacts 2026-04-02 23:24:38 +08:00
2569718930@qq.com 603546f2f9 Align Taipei and Shenzhen settlement sources with Wunderground 2026-04-02 21:46:41 +08:00
2569718930@qq.com f64d3aec83 Filter market pushes by local trading window 2026-04-01 22:01:24 +08:00
2569718930@qq.com 93f4a95e80 Add actionable counts to market digest logs 2026-04-01 21:31:17 +08:00
2569718930@qq.com 59fb1266b9 Broaden market monitor shortlist for tradable markets 2026-04-01 21:28:02 +08:00
2569718930@qq.com f8143f462f Push market focus digests in scan batches 2026-04-01 21:22:01 +08:00
2569718930@qq.com da0b4198ac Add market monitor digest skip diagnostics 2026-04-01 21:14:23 +08:00
2569718930@qq.com ee40045b70 Hardcode Telegram market channel URL and update bot tests 2026-04-01 20:34:41 +08:00
2569718930@qq.com 65688dffd9 Add Telegram market monitor channel link to account center 2026-04-01 20:27:49 +08:00
2569718930@qq.com fdcc57339b Disable Telegram link previews in market digest messages 2026-04-01 20:07:03 +08:00
2569718930@qq.com 9dc21a3088 Skip non-tradable markets in monitor digests 2026-04-01 19:43:57 +08:00
2569718930@qq.com 62cf8f2c4d Update market alert test for renamed Telegram copy 2026-04-01 19:35:07 +08:00
2569718930@qq.com c7c4f6c674 Fix digest push log after interval-based scheduling 2026-04-01 19:28:58 +08:00
2569718930@qq.com 149452dcc0 Switch market digests to interval-based pushes 2026-04-01 19:23:35 +08:00
2569718930@qq.com 9e9e5a56ba Make markets command generate digest asynchronously 2026-04-01 19:08:31 +08:00
2569718930@qq.com 4f017d501b Refine market alert wording and focus digest scheduling 2026-04-01 18:56:39 +08:00
2569718930@qq.com 5fa69556eb Pass config into bot handler registration 2026-04-01 18:49:42 +08:00
2569718930@qq.com 384fd7d1e4 Remove mispricing price cap from market alerts 2026-04-01 18:46:46 +08:00
2569718930@qq.com b1b8d3439c Restrict /markets to private chats 2026-04-01 18:34:58 +08:00
2569718930@qq.com c9719cf577 Add local peak timing and /markets bot digest 2026-04-01 18:24:50 +08:00
2569718930@qq.com 07b2770e9d Update tests for retired wallet monitor and trial timing 2026-04-01 18:02:08 +08:00
2569718930@qq.com 342ed77283 Document market monitor bot settings and retire wallet activity 2026-04-01 17:53:31 +08:00
2569718930@qq.com 400916b023 Move DBManager import into main for sync script 2026-04-01 15:33:42 +08:00
2569718930@qq.com 3bbd8774a4 Sync Telegram profile fields to Supabase and show user IDs 2026-04-01 02:13:17 +08:00
2569718930@qq.com a8462e188b Add external monitoring stack and alerting docs 2026-04-01 01:49:58 +08:00
2569718930@qq.com 6626b5472c Update docs for SQLite migration second-phase cleanup 2026-03-31 23:06:19 +08:00
2569718930@qq.com 79832dbc2b Add analytics funnel cards to the ops dashboard 2026-03-31 10:43:01 +08:00
2569718930@qq.com c29b560401 Add app analytics tracking for paywall and checkout events 2026-03-31 07:15:54 +08:00
2569718930@qq.com 8c8e242753 Refine Istanbul nearby station selection 2026-03-30 19:26:52 +08:00
2569718930@qq.com 2508e4164f Improve map nearby station selection for Istanbul 2026-03-30 19:21:39 +08:00
2569718930@qq.com e4db7d5dea Fix Istanbul MGM station and province matching 2026-03-30 19:14:00 +08:00
2569718930@qq.com 2ee30ce551 Add Moscow and broaden Turkish MGM support 2026-03-30 19:03:13 +08:00
2569718930@qq.com 3899012387 feat: initialize extension manifest with side panel and background service worker configuration 2026-03-30 02:56:11 +08:00
2569718930@qq.com 83824a772e Improve side panel city matching for market aliases 2026-03-30 01:40:37 +08:00
2569718930@qq.com bbda6da39a Remove unused type-checking imports from training script 2026-03-30 01:08:35 +08:00
2569718930@qq.com 4e399d5961 Remove unused weather aura layer from dashboard 2026-03-30 01:04:21 +08:00
2569718930@qq.com 538de5cdd8 Add subscription expiry reminders across account and dashboard 2026-03-30 00:58:43 +08:00
2569718930@qq.com 16404ccf71 Switch repository licensing to AGPL-3.0 2026-03-30 00:46:01 +08:00
2569718930@qq.com ee114fb5bf Add Polymarket market link and refresh side panel docs 2026-03-30 00:09:35 +08:00
2569718930@qq.com 6c7b6de8f9 feat: add manifest.json for side panel extension configuration 2026-03-29 23:54:07 +08:00
2569718930@qq.com c64fa87e05 refactor: consolidate docker-compose service configurations using a shared base anchor 2026-03-29 23:36:53 +08:00
2569718930@qq.com 0cc4f4f0d3 Add LightGBM daily high forecasting pipeline 2026-03-29 23:29:17 +08:00
2569718930@qq.com 4a2db4727b chore: add .dockerignore file to exclude build artifacts and environment files 2026-03-29 22:38:07 +08:00
2569718930@qq.com a5c667473e Remove remote TimesFM integration 2026-03-29 22:27:18 +08:00
2569718930@qq.com cdfc785402 Add remote TimesFM service integration 2026-03-29 22:13:10 +08:00
2569718930@qq.com 5b0506a663 Refresh history loading UI and gate incomplete MGM series 2026-03-29 21:21:52 +08:00
2569718930@qq.com efb5e426b7 Refine history modal loading state and MGM chart gating 2026-03-29 21:09:16 +08:00
2569718930@qq.com d0e1bea941 Backfill missing history forecasts from probability snapshots 2026-03-29 21:01:24 +08:00
2569718930@qq.com 346836f561 Improve history peak scrolling and hide Ankara MGM observations 2026-03-29 20:53:48 +08:00
2569718930@qq.com 2b5d2cd801 Grant new users a three-day signup trial 2026-03-29 20:34:12 +08:00
2569718930@qq.com 6139960a9e Update Shenzhen settlement source to NOAA ZGSZ 2026-03-28 21:32:40 +08:00
2569718930@qq.com cbb704577a Add weather-driven city effects and keep upper-air cards visible 2026-03-27 23:45:40 +08:00
2569718930@qq.com 0c8ad54780 Visualize intraday analysis with 3D signals and metric meters 2026-03-27 23:34:48 +08:00
2569718930@qq.com 98bcfe2a21 Refresh dashboard loading overlay with weather terminal cues 2026-03-27 23:23:51 +08:00
2569718930@qq.com e32cfd8015 Add three.js weather aura layer to dashboard map 2026-03-27 23:09:08 +08:00
2569718930@qq.com 7237278f5a Generalize NOAA settlement handling across dashboard and docs 2026-03-27 20:58:38 +08:00
2569718930@qq.com 15a452dd49 Offset Shek Kong map marker to avoid overlap 2026-03-27 20:22:38 +08:00
2569718930@qq.com 2007ded86d Add HKO-backed Shek Kong settlement and history support 2026-03-27 20:11:08 +08:00
2569718930@qq.com 1ab7db065c Remove Speed Insights from the app layout 2026-03-27 17:48:47 +08:00
2569718930@qq.com 436afe121a Reduce Vercel CPU usage by narrowing auth checks 2026-03-26 17:04:56 +08:00
2569718930@qq.com 88b355e9de Allow scenery images through middleware 2026-03-26 01:32:06 +08:00
2569718930@qq.com b146c637b6 Allow guest city APIs and normalize auth probe responses 2026-03-26 01:25:51 +08:00
2569718930@qq.com 7caae4dceb Unify city detail panel access for guests and free users 2026-03-26 01:19:56 +08:00
2569718930@qq.com 28db3ed428 Fix guest city panel visibility and header labels 2026-03-26 00:53:38 +08:00
2569718930@qq.com 306d701a2d Force fresh Vercel production deploy 2026-03-26 00:47:13 +08:00
2569718930@qq.com 54c93db483 Trigger Vercel production rebuild 2026-03-26 00:42:01 +08:00
2569718930@qq.com 961451aeff Restore guest city panels while keeping Pro data protected 2026-03-26 00:29:14 +08:00
2569718930@qq.com a584da83f7 Avoid duplicate TAF marker points in chart data 2026-03-25 21:54:34 +08:00
2569718930@qq.com 443540c27e Clarify current and peak-window TAF chart markers 2026-03-25 21:46:33 +08:00
2569718930@qq.com ab5c5cfb23 feat: introduce WundergroundSourceMixin for fetching, parsing, and extracting weather observation data from Wunderground. 2026-03-25 19:28:35 +08:00
2569718930@qq.com 7de31ec429 Parse Wunderground history observations for settlement highs 2026-03-25 18:38:34 +08:00
2569718930@qq.com 7589fd74bf feat: Implement Wunderground data integration, enhance dashboard weather data processing, and add future forecast modal. 2026-03-25 18:03:56 +08:00
2569718930@qq.com fd210ac730 Show Wunderground settlement sources in the dashboard 2026-03-25 17:14:25 +08:00
2569718930@qq.com 2b0e6a6f67 Add Wunderground as Shenzhen settlement source 2026-03-25 17:00:36 +08:00
2569718930@qq.com b1ce479909 Remove duplicate docs button and localize docs copy 2026-03-25 16:28:52 +08:00
2569718930@qq.com 6d20d51bb9 Refine docs center content and correct Taipei settlement source 2026-03-25 16:13:42 +08:00
2569718930@qq.com 4e0ddfa771 Add bilingual docs center and replace the guide modal 2026-03-25 15:55:55 +08:00
2569718930@qq.com b866ebf6e3 Clarify TAF intraday summaries 2026-03-25 15:22:41 +08:00
2569718930@qq.com afffbb852b Clarify intraday trading cues with plain-language copy 2026-03-24 19:41:29 +08:00
2569718930@qq.com 69ea686750 Refine TAF summaries and trim peak-window marker overlaps 2026-03-24 19:21:28 +08:00
2569718930@qq.com b7a72bd9ff Update intraday TAF summaries for active and peak windows 2026-03-24 16:32:43 +08:00
2569718930@qq.com 77f2b6ac07 Fix English airport narrative fallback in dashboard 2026-03-24 15:12:49 +08:00
2569718930@qq.com 1dfdd18421 Document TAF signals and expand METAR weather translations 2026-03-24 04:21:23 +08:00
2569718930@qq.com 67e691ed95 Unify intraday summary priority across locales 2026-03-24 04:13:58 +08:00
2569718930@qq.com 1461d02488 feat: Integrate TAF data for non-Hong Kong airport cities, enhance intraday structural analysis, and add new Chinese documentation for API, configuration, and deployment. 2026-03-24 04:03:41 +08:00
2569718930@qq.com 54acfc05aa Clarify TAF timing labels and suppression wording 2026-03-24 03:44:12 +08:00
2569718930@qq.com bbc5dbb8d6 Clarify TAF and surface signal interplay in intraday summary 2026-03-24 03:35:34 +08:00
2569718930@qq.com 29f6542a72 Handle TAF hour rollover in period parsing 2026-03-24 03:27:50 +08:00
2569718930@qq.com 05184cb2ad Add TAF timing markers and market-aware intraday cues 2026-03-24 03:13:54 +08:00
2569718930@qq.com b365155622 Add TAF-based airport signals to intraday analysis 2026-03-24 02:58:29 +08:00
2569718930@qq.com fc72b3539d Align structural trade cues with market context 2026-03-24 02:07:15 +08:00
2569718930@qq.com 5796d380c5 Reorganize intraday signal cards for clearer trade guidance 2026-03-24 01:49:00 +08:00
2569718930@qq.com 58a09fe6fe Add upper-air structure signals to intraday analysis 2026-03-24 01:28:45 +08:00
2569718930@qq.com 4ac690228e Bootstrap recent METAR history for new cities 2026-03-24 01:02:18 +08:00
2569718930@qq.com 90822c81d4 Add METAR backfill script and remove settlement history fallback 2026-03-24 00:55:16 +08:00
2569718930@qq.com 5e83a5cca3 feat: Introduce core API routes for city data, history, authentication, and payments, along with a new frontend dashboard utility file. 2026-03-24 00:04:24 +08:00
2569718930@qq.com 578e50b2da Fix E402 imports in model gap backfill script 2026-03-23 23:46:02 +08:00
2569718930@qq.com 5ac5caf3ef Preserve model forecasts and add gap backfill script 2026-03-23 23:41:56 +08:00
2569718930@qq.com 7adec4e17c Add peak-12h DEB history reference and bump extension to 0.1.5 2026-03-23 23:17:48 +08:00
2569718930@qq.com 354d55f752 Use DEB values in multi-day forecasts 2026-03-23 21:41:27 +08:00
2569718930@qq.com 6feb4ff489 Show weekly points in account center 2026-03-23 21:32:18 +08:00
2569718930@qq.com fcaa9d5c7f Store HKO intraday observations in runtime state 2026-03-23 21:28:04 +08:00
2569718930@qq.com b1102400f6 Add HKO official observation series for Hong Kong charts 2026-03-23 21:22:41 +08:00
2569718930@qq.com 5e31bc9ea3 Add per-chat bot message cooldown overrides 2026-03-23 21:05:40 +08:00
2569718930@qq.com ff7d77ef06 Relax bot polling update restrictions 2026-03-23 20:53:09 +08:00
2569718930@qq.com ce22752ccc Add bot message activity diagnostics and bump extension to 0.1.4 2026-03-23 20:46:42 +08:00
2569718930@qq.com 8a091fd7cc Add basic decision bias and site CTA to side panel 2026-03-23 18:28:11 +08:00
180 changed files with 30874 additions and 5109 deletions
+31
View File
@@ -0,0 +1,31 @@
.git
.github
.vscode
.agent
.env
.env.*
!.env.example
!.env.secrets.example
venv
.venv
.uv-cache
.uv-python
.pytest_cache
.ruff_cache
.mypy_cache
__pycache__
.npm-cache
frontend/node_modules
artifacts
notebooks
bot.log
*.log
extension.zip
tmp_*.js
tmp_*.html
+36 -4
View File
@@ -17,7 +17,13 @@ OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
# Windows / macOS can usually keep the defaults.
UID=1000
GID=1000
POLYWEATHER_STATE_STORAGE_MODE=dual
POLYWEATHER_STATE_STORAGE_MODE=sqlite
POLYWEATHER_PROMETHEUS_PORT=9090
POLYWEATHER_ALERTMANAGER_PORT=9093
POLYWEATHER_ALERT_RELAY_PORT=9099
POLYWEATHER_GRAFANA_PORT=3001
POLYWEATHER_GRAFANA_ADMIN_USER=admin
POLYWEATHER_GRAFANA_ADMIN_PASSWORD=polyweather
########################################
# 2) Telegram bot minimal
@@ -39,9 +45,20 @@ OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=7200
OPEN_METEO_MULTI_MODEL_CACHE_VERSION=v2
OPEN_METEO_RATE_LIMIT_COOLDOWN_SEC=900
OPEN_METEO_RATE_CACHE_TTL_SEC=3600
OPEN_METEO_MIN_CALL_INTERVAL_SEC=3
OPEN_METEO_MIN_CALL_INTERVAL_SEC=1
POLYWEATHER_HTTP_TIMEOUT_SEC=8
POLYWEATHER_HTTP_RETRY_COUNT=0
POLYWEATHER_HTTP_RETRY_BACKOFF_SEC=0.2
POLYWEATHER_OPEN_METEO_TIMEOUT_SEC=5
POLYWEATHER_METAR_TIMEOUT_SEC=4
POLYWEATHER_METAR_CLUSTER_TIMEOUT_SEC=3.5
METAR_CACHE_TTL_SEC=600
JMA_AMEDAS_CACHE_TTL_SEC=120
METEOBLUE_CACHE_TTL_SEC=7200
POLYWEATHER_LGBM_ENABLED=false
POLYWEATHER_LGBM_MODEL_PATH=/app/artifacts/models/lgbm_daily_high.txt
POLYWEATHER_LGBM_SCHEMA_PATH=/app/artifacts/models/lgbm_daily_high_schema.json
POLYWEATHER_LGBM_MIN_HISTORY_POINTS=3
########################################
# 4) Auth / entitlement
@@ -66,19 +83,34 @@ TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
TELEGRAM_ALERT_MIN_SEVERITY=medium
TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY=0.10
TELEGRAM_ALERT_MISPRICING_ONLY=true
TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,tel aviv,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
########################################
# 6) Frontend-facing shared values
########################################
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
# Optional: disable homepage city summary preloading. Default is enabled.
NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false
########################################
# 7) Optional modules
########################################
# Optional Groq commentary rewrite for intraday structure cards
POLYWEATHER_GROQ_COMMENTARY_ENABLED=false
GROQ_API_KEY=
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,guangzhou,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
# Weekly reward / leaderboard
POLYWEATHER_WEEKLY_REWARD_ENABLED=true
POLYWEATHER_WEEKLY_REWARD_TIMEZONE=Asia/Shanghai
@@ -147,7 +179,7 @@ POLYGON_WALLET_WATCH_POLYMARKET_ONLY=true
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
# Polymarket wallet activity
# Polymarket wallet activity (retired; replaced by market monitor digests + critical alerts)
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
POLYMARKET_WALLET_ACTIVITY_USERS=
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=
+31
View File
@@ -1,5 +1,20 @@
# Changelog
## 1.5.3 - 2026-04-10
- 东京新增 `JMA AMeDAS` 羽田 10 分钟官方增强层,只取温度并作为机场周边官方参考
- 韩国官方增强层补齐 `KMA` 接入链,与 `METAR` 锚点保持分离
- 城市点击交互恢复地图 `flyTo` 放大动画,并补回明确的 loading 提示
- 城市点击后新增地图顶部同步提醒与详情面板内同步徽标,降低“看起来像卡住”的误判
- 城市 detail 现在会识别“单模型 / 单日”的稀疏缓存并自动强刷,修复“模型只剩 DEB / 多日预报只剩今天”这类残缺展示
- 前端多日预报在窄面板下改为可横向滚动,并对稀疏日序列给出刷新提示
- `/ops``/api/system/status` 新增 prewarm worker 运行态、heartbeat、summary/detail/market 统计,以及缓存桶状态与 summary cache hit/miss
- 新增 Dashboard 定向预热脚本、后台 worker 和 docker service,支持热点城市 summary/detail/market 预热
- 共享天气采集 HTTP 层进一步统一到 `httpx` helper,并补齐短重试与错误分类
- 今日日内分析改造成更交易化的工作台结构:`锚点状态 / 当前节奏 / 当前命中胜率 / 模型区间与分歧 / 今日日内结构信号`
- 今日日内结构解读新增可选 `Groq` 改写层,失败时自动回退到规则文案
- 文档统一更新到 `v1.5.3`,补充预热 worker、Groq、Vercel 节流与官方增强站网说明
## 1.5.1 - 2026-03-23
- `/ops` 页面增加管理员守卫,前后端双层限制管理员访问
@@ -15,6 +30,22 @@
- 台北主结算源切换到 `NOAA RCTP`,按最终质控后的最高整度摄氏值展示和说明
- 浏览器插件同步台北 `NOAA RCTP` 结算参考标签和说明
- `/ops` 手机端收口为卡片化视图,保留桌面表格
- 账户中心补充本周积分显示,`weekly_points` 与周排行同屏展示
- Dashboard 历史对账补充“峰值前 12 小时 DEB 参考(近似)”卡片
- 历史图不再错误混入 `settlement_history` 实测,历史样本仅按可比较样本统计
- 新增 `scripts/backfill_recent_daily_actuals_from_metar.py`,支持为缺失 `daily_records` 的 METAR 城市补最近 14 天 `actual_high`
- 历史接口对新接入的 METAR 城市增加自动 bootstrap,避免新增城市历史页整块空白
- 香港历史/日内展示继续坚持 `HKO` 官方口径,不再 fallback 到 `VHHH METAR` 连续线
- 香港 HKO 当天官方点位不再落单独 JSON,统一写入 runtime state
- 今日日内结构信号按城市本地时间与峰值窗口分析,不再只看固定下午时段
- 新增高空结构信号:冲高环境、压温风险、午后扰动、冲高效率,并提供中英文说明
- 新增交易动作卡:结合高空结构、市场拥挤度与 `edge_percent` 输出 `偏暖侧 / 偏谨慎 / 先观察`
- 非香港机场城市新增 `TAF` 接入,支持 `FM / TEMPO / BECMG / PROB30/40` 时间片解析
- 温度走势图新增 `TAF 时段 / TAF Timing` 标记,并在 tooltip 中显示对应时段摘要
- `TAF` 信号与 `market_signal / edge_percent` 联动进入交易动作,提示更贴近交易语境
- `TAF` 展示词已改成普通用户可读版本:`基础时段 / 明确切换 / 临时波动 / 逐步转变`
- 日内结构总摘要补充“TAF 未新增压温不等于继续升温”的解释,避免误读
- 浏览器插件多日预报改为 `DEB` 优先,基础判断卡补充方向、置信度与原因,并统一引流到主站首页
## Unreleased
+3 -1
View File
@@ -6,6 +6,8 @@ WORKDIR /app
# 设置环境变量
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_ROOT_USER_ACTION=ignore \
TZ=UTC
# 安装系统依赖 (如果有必要的包可以取消注释)
@@ -15,7 +17,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
COPY requirements.txt .
# 安装 Python 依赖
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir --prefer-binary -r requirements.txt
# 复制项目代码
COPY . .
+657 -17
View File
@@ -1,21 +1,661 @@
MIT License
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (c) 2026 Yuanzhen Yang (yangyuan-zhen)
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+70 -17
View File
@@ -3,6 +3,9 @@
Production weather-intelligence stack for temperature settlement markets.
Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
中文说明: [README_ZH.md](README_ZH.md)
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday signals, TAF, settlement sources, history, and extension).
## Product Screenshots
@@ -14,7 +17,7 @@ Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.
![PolyWeather Ankara analysis](docs/images/demo_ankara.png)
## Product Status (2026-03-21)
## Product Status (2026-04-10)
- Subscription live: `Pro Monthly 5 USDC`.
- Points redemption live: `500 points = 1 USDC`, max `3 USDC` off.
@@ -22,26 +25,42 @@ Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.
- Auto-reconciliation live: event listener + periodic confirm loop.
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
- Runtime state supports gradual SQLite migration (`file / dual / sqlite`).
- Runtime state, cache, and core offline training/backfill flows now use SQLite as the primary path; legacy JSON/JSONL files remain only for migration, export, and explicit fallback input.
- EMOS/CRPS pipeline is integrated in `shadow` mode with rollout gating.
- Intraday structural signal is now peak-window aware and bilingual (`zh-CN` / `en-US`).
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
- Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`.
- Browser extension now uses `DEB` for multi-day forecast and stays positioned as a lightweight lead-in to the main site.
- Official nearby-network layer now covers `MGM` (Turkey), `CMA/NMC` (Mainland China), `JMA AMeDAS` (Japan), `KMA` (Korea), `HKO` (Hong Kong), and `CWA` (Taiwan).
- Tokyo now ingests Haneda `JMA AMeDAS` 10-minute temperature as the official enhancement layer.
- Dashboard prewarm is now supported through a dedicated worker / cron path, with runtime status exposed in `/api/system/status` and `/ops`.
- `/ops` now exposes cache bucket counts, summary cache hit / miss rate, and prewarm runtime heartbeat.
- Intraday commentary can optionally use `Groq` as a bilingual rewrite layer, while rule-based commentary remains the fallback.
- Vercel frontend guidance now includes cost controls for analytics, eager fetches, and edge-side scanner blocking.
## Open-Core Boundary (Important)
## License & Commercial Boundary
This repository follows an **Open-Core** strategy:
This repository is licensed under **GNU AGPL-3.0 only** from `2026-03-30` onward.
- Public in repo: weather aggregation, core analysis, dashboard, bot baseline, standard payment flow.
- Private in production: commercial risk rules, operational thresholds, pricing strategy details, internal reconciliation policies, and growth operations tooling.
- Public in repo: weather aggregation, core analysis, dashboard, bot baseline, and standard payment flow.
- Not included in this repository: private production data, internal operating thresholds, commercial risk rules, pricing strategy details, and growth tooling.
- Trademark, brand, domain, production databases, and hosted-service operations are **not** granted by the code license.
See: [Open-Core & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
## Core Capabilities
- Aggregates observations and forecasts for 30 monitored cities.
- Aggregates observations and forecasts for 45 monitored cities.
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
- Generates settlement-oriented probability buckets (`mu` + bucket distribution).
- Maps weather view to Polymarket quotes for mispricing scan.
- Reuses one analysis core across web dashboard and Telegram bot.
- Adds payment audit trails, replay tooling, and incident visibility in ops.
- Adds peak-window-oriented intraday structure cards for surface + upper-air analysis.
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
- Adds official nearby-network enhancement layers for China, Japan, Korea, Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
- Adds optional dashboard prewarm worker so hot cities can be refreshed before user clicks.
## Reference Architecture
@@ -54,21 +73,26 @@ flowchart LR
API --> WX["Weather Collector"]
WX --> METAR["Aviation Weather (METAR)"]
WX --> TAF["Aviation Weather (TAF)"]
WX --> MGM["MGM (Turkey station network)"]
WX --> OM["Open-Meteo"]
WX --> JMA["JMA AMeDAS (Japan)"]
WX --> KMA["KMA (Korea)"]
WX --> HKO["HKO / CWA / NOAA / Official settlement sources"]
API --> ANA["DEB + Trend + Probability + Market Scan"]
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
ANA --> PM["Polymarket Read-only Layer"]
ANA --> LLM["Optional Groq Commentary Rewrite"]
API --> PREWARM["Dashboard Prewarm API / Worker"]
```
## Monitored Cities (30)
## Monitored Cities (45)
- Europe / Middle East: Ankara, London, Paris, Munich, Tel Aviv, Milan, Warsaw, Madrid
- APAC: Seoul, Hong Kong, Taipei, Shanghai, Singapore, Tokyo, Wellington
- Americas: Toronto, New York, Chicago, Dallas, Miami, Atlanta, Seattle, Buenos Aires, Sao Paulo
- Europe / Middle East: Ankara, Istanbul, Moscow, London, Paris, Munich, Milan, Warsaw, Madrid, Tel Aviv, Amsterdam, Helsinki
- APAC: Seoul, Busan, Hong Kong, Lau Fau Shan, Taipei, Shanghai, Beijing, Wuhan, Chengdu, Chongqing, Shenzhen, Singapore, Tokyo, Kuala Lumpur, Jakarta, Wellington
- Americas: Toronto, New York, Los Angeles, San Francisco, Denver, Austin, Houston, Chicago, Dallas, Miami, Atlanta, Seattle, Mexico City, Buenos Aires, Sao Paulo, Panama City
- South Asia: Lucknow
- China extension: Chengdu, Chongqing, Shenzhen, Beijing, Wuhan
## Quick Start
@@ -82,10 +106,22 @@ docker compose up -d --build
```bash
cd frontend
npm install
npm ci
npm run dev
```
## Recent Highlights
- Taipei settlement is aligned to `Wunderground RCSS` with whole-degree Celsius resolution logic.
- Shenzhen settlement is aligned to `Wunderground ZGSZ`.
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
- Intraday analysis now separates:
- `Surface Structure`
- `Upper-Air Structure`
- `Trade cue`
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
- Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience.
## Runtime Data (Recommended on VPS)
Use external runtime storage to avoid SQLite/git conflicts:
@@ -93,6 +129,7 @@ Use external runtime storage to avoid SQLite/git conflicts:
```env
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
POLYWEATHER_STATE_STORAGE_MODE=sqlite
```
## Ops Verification
@@ -105,6 +142,20 @@ curl http://127.0.0.1:8000/api/system/status
curl http://127.0.0.1:8000/metrics
```
### Dashboard prewarm worker
```bash
docker compose --profile workers up -d polyweather_prewarm
curl http://127.0.0.1:8000/api/system/status
```
Check:
- `prewarm.thread_alive`
- `prewarm.runtime.cycle_count`
- `cache.analysis.hit_rate`
- `cache.open_meteo_forecast_entries`
### Frontend cache headers
```bash
@@ -144,10 +195,12 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
- Chinese overview: [README_ZH.md](README_ZH.md)
- Chinese API guide: [docs/API_ZH.md](docs/API_ZH.md)
- TAF signal guide (ZH): [docs/TAF_SIGNAL_ZH.md](docs/TAF_SIGNAL_ZH.md)
- Commercialization: [docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
- Open-Core policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
- AGPL-3.0 policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
- Supabase setup (ZH): [docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
- Configuration & secrets (ZH): [docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
- LightGBM daily-high model (ZH): [docs/LGBM_DAILY_HIGH_ZH.md](docs/LGBM_DAILY_HIGH_ZH.md)
- Frontend deployment (ZH): [docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
- Tech debt (EN): [docs/TECH_DEBT.md](docs/TECH_DEBT.md)
- Tech debt (ZH): [docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
@@ -162,5 +215,5 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
## Version
- Version: `v1.5.1`
- Last Updated: `2026-03-21`
- Version: `v1.5.3`
- Last Updated: `2026-04-10`
+70 -19
View File
@@ -14,7 +14,7 @@
![PolyWeather Ankara 分析页](docs/images/demo_ankara.png)
## 当前产品状态(2026-03-21
## 当前产品状态(2026-04-10
- 已上线订阅制:`Pro 月付 5 USDC`
- 已上线积分抵扣:`500 积分 = 1 USDC`,最多抵扣 `3 USDC`
@@ -23,26 +23,42 @@
- 已上线支付运行态与审计接口:`/api/payments/runtime`
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。
- 已上线轻量可观测性:`/healthz``/api/system/status``/metrics`
- 运行态状态与缓存已支持 SQLite 渐进迁移:`file / dual / sqlite`
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay
- 运行态状态、缓存与核心离线训练/回填链路已完成 SQLite 主路径收口;legacy JSON/JSONL 仅保留给迁移、导出与显式回退输入。
- 已接入 EMOS/CRPS 校准链路,但当前仍保持 `emos_shadow`
- 官方增强站网已统一接入:
- `MGM`(土耳其)
- `CMA/NMC`(中国内地)
- `JMA AMeDAS`(日本)
- `KMA`(韩国)
- `HKO`(香港)
- `CWA`(台湾)
- 东京现已接入羽田 `JMA AMeDAS` 10 分钟温度作为官方增强层。
- 已支持 Dashboard 定向预热 worker / cron 路径,运行态在 `/api/system/status``/ops` 可见。
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与 prewarm heartbeat。
- 今日日内结构解读已支持可选 `Groq` 改写层,失败时自动回退规则文案。
- 前端部署文档已补充 Vercel 节流建议,包括 analytics 关闭、eager fetch 开关与扫描流量防火墙规则。
## 开源边界(重要)
## 许可证与商用边界(重要)
项目采用 **Open-Core** 策略:
仓库自 `2026-03-30` 起采用 **GNU AGPL-3.0-only**
- 仓库公开部分:天气聚合、基础分析、前端看板、Bot 基础能力、支付标准流程示例
- 生产私有部分:商业风控规则、运营阈值、收费策略细节、付费用户运营脚本、内部对账与审计策略
- 仓库公开部分:天气聚合、基础分析、前端看板、Bot 基础能力、标准支付流程
- 不包含在仓库中的部分:生产私有数据、商业风控规则、运营阈值、收费策略细节、内部对账与增长工具
- 商标、品牌、域名、生产数据库与托管服务运营能力,不因代码许可证一并授权。
详细见:[Open-Core 与商用边界](docs/OPEN_CORE_POLICY.md)
详细见:[AGPL-3.0 与商用边界](docs/OPEN_CORE_POLICY.md)
## 核心能力
- 聚合 30 个监控城市的实测与预报数据。
- 聚合 45 个监控城市的实测与预报数据。
- DEBDynamic Error Balancing)融合多模型最高温。
- 输出结算导向概率分布(`mu` + 温度桶)。
- 将模型观点映射到 Polymarket 行情,做错价扫描。
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
- 官方增强层支持按国家 provider 统一接入,但不替代机场主站或明确官方结算站。
- 支持后台预热热点城市,降低用户点击城市后的冷启动成本。
## 参考架构
@@ -56,22 +72,26 @@ flowchart LR
API --> WX["Weather Collector"]
WX --> METAR["Aviation WeatherMETAR"]
WX --> MGM["MGM(土耳其站网)"]
WX --> JMA["JMA AMeDAS(日本)"]
WX --> KMA["KMA(韩国)"]
WX --> OM["Open-Meteo"]
WX --> HKO["HKO / CWA / NOAA 等官方结算源"]
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
ANA --> PAY["支付状态(Intent + Event + Confirm Loop"]
ANA --> PM["Polymarket 只读层"]
API --> OBS["healthz / system status / metrics"]
ANA --> STATE["SQLite runtime state + dual fallback"]
API --> PREWARM["Dashboard 预热接口 / Worker"]
ANA --> LLM["可选 Groq 文案改写层"]
ANA --> STATE["SQLite runtime state<br/>legacy files only for migration/export fallback"]
```
## 监控城市(30
## 监控城市(45
- 欧洲/中东:Ankara、London、Paris、Munich、Tel Aviv、Milan、Warsaw、Madrid
- 亚太:Seoul、Hong Kong、Taipei、Shanghai、Singapore、Tokyo、Wellington
- 美洲:Toronto、New York、Chicago、Dallas、Miami、Atlanta、Seattle、Buenos Aires、Sao Paulo
- 欧洲/中东:Ankara、Istanbul、Moscow、London、Paris、Munich、Milan、Warsaw、Madrid、Tel Aviv、Amsterdam、Helsinki
- 亚太:Seoul、Busan、Hong Kong、Lau Fau Shan、Taipei、Shanghai、Beijing、Wuhan、Chengdu、Chongqing、Shenzhen、Singapore、Tokyo、Kuala Lumpur、Jakarta、Wellington
- 美洲:Toronto、New York、Los Angeles、San Francisco、Denver、Austin、Houston、Chicago、Dallas、Miami、Atlanta、Seattle、Mexico City、Buenos Aires、Sao Paulo、Panama City
- 南亚:Lucknow
- 中国扩展:Chengdu、Chongqing、Shenzhen、Beijing、Wuhan
## 快速启动
@@ -85,7 +105,7 @@ docker compose up -d --build
```bash
cd frontend
npm install
npm ci
npm run dev
```
@@ -96,7 +116,7 @@ npm run dev
```env
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
POLYWEATHER_STATE_STORAGE_MODE=dual
POLYWEATHER_STATE_STORAGE_MODE=sqlite
```
## 运维验收
@@ -109,6 +129,20 @@ curl http://127.0.0.1:8000/api/system/status
curl http://127.0.0.1:8000/metrics
```
### Dashboard 预热 Worker
```bash
docker compose --profile workers up -d polyweather_prewarm
curl http://127.0.0.1:8000/api/system/status
```
重点关注:
- `prewarm.thread_alive`
- `prewarm.runtime.cycle_count`
- `prewarm.runtime.last_summary_ok`
- `cache.analysis.hit_rate`
### 前端缓存头
```bash
@@ -121,6 +155,22 @@ curl http://127.0.0.1:8000/metrics
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
```
### 外部监控栈
```bash
docker compose --profile monitoring up -d polyweather_prometheus polyweather_alertmanager polyweather_alert_relay polyweather_grafana
```
- Prometheus`http://127.0.0.1:${POLYWEATHER_PROMETHEUS_PORT:-9090}`
- Alertmanager`http://127.0.0.1:${POLYWEATHER_ALERTMANAGER_PORT:-9093}`
- Grafana`http://127.0.0.1:${POLYWEATHER_GRAFANA_PORT:-3001}`
手动巡检:
```bash
python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
```
### 支付运行态
```bash
@@ -158,7 +208,7 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
- 英文总览:[README.md](README.md)
- API 文档(中文):[docs/API_ZH.md](docs/API_ZH.md)
- 商业化说明:[docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
- Open-Core 边界:[docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
- AGPL-3.0 边界:[docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
- Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
- 配置与密钥管理:[docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
- 前端部署(Vercel):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
@@ -170,6 +220,7 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
- 支付审计说明:[docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
- 前端报告:[FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
- 发布流程:[RELEASE.md](RELEASE.md)
@@ -177,5 +228,5 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
## 当前版本
- 版本:`v1.5.1`
- 文档最后更新:`2026-03-21`
- 版本:`v1.5.3`
- 文档最后更新:`2026-04-10`
+1 -1
View File
@@ -1 +1 @@
1.5.1
1.5.3
+533
View File
@@ -0,0 +1,533 @@
tree
version=v4
num_class=1
num_tree_per_iteration=1
label_index=0
max_feature_idx=26
objective=regression
feature_names=actual_high_lag_1 actual_high_lag_2 actual_high_lag_3 actual_high_lag_7 actual_high_mean_7 actual_high_mean_14 actual_high_trend_3 open_meteo ecmwf gfs gem jma icon mgm nws deb_prediction model_median model_spread current_temp max_so_far humidity wind_speed_kt visibility_mi local_hour month weekday peak_status_code
feature_infos=[2:33] [5.5999999046325684:28] [5.5999999046325684:28] [5.4000000953674316:27.100000381469727] [7.5714287757873535:27.871429443359375] [8.9142856597900391:26.335714340209961] [-14:11] [7.5:30.899999618530273] [7.5:30.899999618530273] [8.1999998092651367:32.900001525878906] [8.1999998092651367:30.700000762939453] [8.3000001907348633:30.799999237060547] [7.5:30.899999618530273] none none [7.9000000953674316:30.899999618530273] [8.1999998092651367:30.899999618530273] [0.30000001192092896:7.5999999046325684] [0:29.200000762939453] [2:32.099998474121094] [46.900001525878906:81] [1:17] none [5:20] none [0:6] [0:2]
tree_sizes=411 401 404 404 426 314 427 422 423 424 430 427 519 431 427 518 427 429 500 427
Tree=0
num_leaves=3
num_cat=0
split_feature=10 2
split_gain=1001.8 123.037
threshold=20.04999923706055 19.94999980926514
decision_type=10 10
left_child=-1 -2
right_child=1 -3
leaf_value=19.431389017899832 20.051389029050867 20.38722237745921
leaf_weight=6.0000000000000027 4.9999999999999982 6
leaf_count=6 5 6
internal_value=19.9511 20.2346
internal_weight=17 11
internal_count=17 11
is_linear=0
shrinkage=1
Tree=1
num_leaves=3
num_cat=0
split_feature=7 0
split_gain=858.905 67.8176
threshold=15.950000286102297 25.85000038146973
decision_type=8 2
left_child=-1 -2
right_child=1 -3
leaf_value=-0.50156944513320922 0.1882326394319534 0.47938890457153316
leaf_weight=4.9999999999999991 4 4
leaf_count=5 4 4
internal_value=0.0125107 0.333811
internal_weight=13 8
internal_count=13 8
is_linear=0
shrinkage=0.05
Tree=2
num_leaves=3
num_cat=0
split_feature=10 0
split_gain=763.646 100.764
threshold=20.04999923706055 25.85000038146973
decision_type=10 2
left_child=-1 -2
right_child=1 -3
leaf_value=-0.43713240964072081 0.10051891766488551 0.45541944503784171
leaf_weight=7.0000000000000009 4 4
leaf_count=7 4 4
internal_value=-0.0557449 0.277969
internal_weight=15 8
internal_count=15 8
is_linear=0
shrinkage=0.05
Tree=3
num_leaves=3
num_cat=0
split_feature=10 0
split_gain=466.421 90.9391
threshold=20.04999923706055 25.85000038146973
decision_type=10 2
left_child=-1 -2
right_child=1 -3
leaf_value=-0.35153238058090203 0.095492970943450911 0.43264847993850702
leaf_weight=5.0000000000000009 4 4
leaf_count=5 4 4
internal_value=0.0273003 0.264071
internal_weight=13 8
internal_count=13 8
is_linear=0
shrinkage=0.05
Tree=4
num_leaves=3
num_cat=0
split_feature=8 20
split_gain=680.381 113.467
threshold=16.19999980926514 inf
decision_type=2 8
left_child=-1 -2
right_child=1 -3
leaf_value=-0.44872442086537651 0.38909818649292011 0.066590625792741762
leaf_weight=6.0000000000000027 4.9999999999999973 6.0000000000000009
leaf_count=6 5 6
internal_value=-0.0204301 0.213185
internal_weight=17 11
internal_count=17 11
is_linear=0
shrinkage=0.05
Tree=5
num_leaves=2
num_cat=0
split_feature=3
split_gain=658.731
threshold=13.449999809265138
decision_type=10
left_child=-1
right_child=-2
leaf_value=-0.3789437881537846 0.33501165111859638
leaf_weight=7 6
leaf_count=7 6
internal_value=-0.0494259
internal_weight=13
internal_count=13
is_linear=0
shrinkage=0.05
Tree=6
num_leaves=3
num_cat=0
split_feature=7 20
split_gain=619.423 90.8198
threshold=15.950000286102297 inf
decision_type=8 8
left_child=-1 -2
right_child=1 -3
leaf_value=-0.47567430973052949 0.35289269924163835 0.064359399676322912
leaf_weight=5.0000000000000027 4.9999999999999973 6.0000000000000009
leaf_count=5 5 6
internal_value=-0.0142345 0.195511
internal_weight=16 11
internal_count=16 11
is_linear=0
shrinkage=0.05
Tree=7
num_leaves=3
num_cat=0
split_feature=10 0
split_gain=520.638 78.456
threshold=20.04999923706055 25.85000038146973
decision_type=8 2
left_child=-1 -2
right_child=1 -3
leaf_value=-0.38976687689622219 0.067351022859414439 0.33552634239196771
leaf_weight=6.0000000000000027 5.9999999999999982 5
leaf_count=6 6 5
internal_value=-0.0151096 0.189249
internal_weight=17 11
internal_count=17 11
is_linear=0
shrinkage=0.05
Tree=8
num_leaves=3
num_cat=0
split_feature=10 2
split_gain=499.553 74.407
threshold=20.04999923706055 19.94999980926514
decision_type=8 10
left_child=-1 -2
right_child=1 -3
leaf_value=-0.37027853230635305 0.063983469704786952 0.31299318869908649
leaf_weight=6.0000000000000027 5.9999999999999982 6
leaf_count=6 6 6
internal_value=0.00223271 0.188488
internal_weight=18 12
internal_count=18 12
is_linear=0
shrinkage=0.05
Tree=9
num_leaves=3
num_cat=0
split_feature=7 21
split_gain=453.956 67.1434
threshold=15.950000286102297 3.6000000238418584
decision_type=8 10
left_child=-1 -2
right_child=1 -3
leaf_value=-0.3529831647872923 0.31961423397064215 0.07971531493323189
leaf_weight=6.0000000000000027 4.9999999999999982 7
leaf_count=6 5 7
internal_value=0.00212108 0.179673
internal_weight=18 12
internal_count=18 12
is_linear=0
shrinkage=0.05
Tree=10
num_leaves=3
num_cat=0
split_feature=18 19
split_gain=417.969 98.6176
threshold=10.500000000000002 23.500000000000004
decision_type=8 10
left_child=-1 -2
right_child=1 -3
leaf_value=-0.44873919486999481 -0.0019017819847379415 0.26350565637860973
leaf_weight=4.0000000000000027 6.9999999999999982 7
leaf_count=4 7 7
internal_value=0.00201502 0.130802
internal_weight=18 14
internal_count=18 14
is_linear=0
shrinkage=0.05
Tree=11
num_leaves=3
num_cat=0
split_feature=10 10
split_gain=379.422 54.6231
threshold=20.04999923706055 27.150000572204593
decision_type=8 10
left_child=-1 -2
right_child=1 -3
leaf_value=-0.32273159126440665 0.074079313874244698 0.29045824527740471
leaf_weight=6.0000000000000027 6.9999999999999982 5
leaf_count=6 7 5
internal_value=0.00191427 0.164237
internal_weight=18 12
internal_count=18 12
is_linear=0
shrinkage=0.05
Tree=12
num_leaves=4
num_cat=0
split_feature=18 2 6
split_gain=349.161 79.9137 12.1022
threshold=10.500000000000002 19.94999980926514 1.5500000119209292
decision_type=8 10 8
left_child=-1 2 -2
right_child=1 -3 -4
leaf_value=-0.41016564369201625 0.077571737766265914 0.25746693611145016 -0.045422995835542657
leaf_weight=4.0000000000000027 3.9999999999999973 6 4.0000000000000009
leaf_count=4 4 6 4
internal_value=0.00181856 0.119528 0.0160744
internal_weight=18 14 8
internal_count=18 14 8
is_linear=0
shrinkage=0.05
Tree=13
num_leaves=3
num_cat=0
split_feature=18 19
split_gain=315.118 72.4693
threshold=10.500000000000002 23.500000000000004
decision_type=8 10
left_child=-1 -2
right_child=1 -3
leaf_value=-0.38965735435485804 -0.00020638235977717812 0.22731021472385948
leaf_weight=4.0000000000000027 6.9999999999999982 7
leaf_count=4 7 7
internal_value=0.00172763 0.113552
internal_weight=18 14
internal_count=18 14
is_linear=0
shrinkage=0.05
Tree=14
num_leaves=3
num_cat=0
split_feature=7 21
split_gain=287.831 40.4927
threshold=15.950000286102297 3.6000000238418584
decision_type=8 10
left_child=-1 -2
right_child=1 -3
leaf_value=-0.28111823151508952 0.25169647216796881 0.065395631534712659
leaf_weight=6.0000000000000027 4.9999999999999982 7
leaf_count=6 5 7
internal_value=0.00164124 0.143021
internal_weight=18 12
internal_count=18 12
is_linear=0
shrinkage=0.05
Tree=15
num_leaves=4
num_cat=0
split_feature=18 2 6
split_gain=263.177 58.9207 10.397
threshold=10.500000000000002 19.94999980926514 1.5500000119209292
decision_type=8 10 8
left_child=-1 2 -2
right_child=1 -3 -4
leaf_value=-0.35611858367919891 0.071921156719327015 0.22219576636950175 -0.042079883068799957
leaf_weight=4.0000000000000027 3.9999999999999973 6 4.0000000000000009
leaf_count=4 4 6 4
internal_value=0.00155919 0.103753 0.0149206
internal_weight=18 14 8
internal_count=18 14 8
is_linear=0
shrinkage=0.05
Tree=16
num_leaves=3
num_cat=0
split_feature=7 21
split_gain=237.631 32.9682
threshold=15.950000286102297 3.6000000238418584
decision_type=8 10
left_child=-1 -2
right_child=1 -3
leaf_value=-0.25544037123521152 0.22800186157226565 0.059899282455444326
leaf_weight=6.0000000000000027 4.9999999999999982 7
leaf_count=6 5 7
internal_value=0.00148123 0.129942
internal_weight=18 12
internal_count=18 12
is_linear=0
shrinkage=0.05
Tree=17
num_leaves=3
num_cat=0
split_feature=18 19
split_gain=219.898 47.5691
threshold=10.500000000000002 23.500000000000004
decision_type=8 10
left_child=-1 -2
right_child=1 -3
leaf_value=-0.3255406379699704 0.0026553549936839515 0.18698629140853881
leaf_weight=4.0000000000000027 6.9999999999999982 7
leaf_count=4 7 7
internal_value=0.00140717 0.0948208
internal_weight=18 14
internal_count=18 14
is_linear=0
shrinkage=0.05
Tree=18
num_leaves=4
num_cat=0
split_feature=11 21 12
split_gain=200.198 25.8458 5.04583
threshold=16.19999980926514 3.6000000238418584 22.10000038146973
decision_type=8 8 10
left_child=-1 -2 -3
right_child=1 2 -4
leaf_value=-0.23448222527901319 0.22302051782608043 0.027650000527501103 0.10706844031810758
leaf_weight=6.0000000000000027 3.9999999999999973 4 4
leaf_count=6 4 4 4
internal_value=0.0013368 0.119246 0.0673592
internal_weight=18 12 8
internal_count=18 12 8
is_linear=0
shrinkage=0.05
Tree=19
num_leaves=3
num_cat=0
split_feature=10 10
split_gain=180.679 24.1787
threshold=20.04999923706055 27.150000572204593
decision_type=8 10
left_child=-1 -2
right_child=1 -3
leaf_value=-0.22275811135768878 0.053300471178122935 0.19726094961166379
leaf_weight=6.0000000000000027 6.9999999999999982 5
leaf_count=6 7 5
internal_value=0.00126997 0.113284
internal_weight=18 12
internal_count=18 12
is_linear=0
shrinkage=0.05
end of trees
feature_importances:
gem=9
open_meteo=5
current_temp=5
actual_high_lag_1=4
actual_high_lag_3=4
wind_speed_kt=4
max_so_far=3
actual_high_trend_3=2
humidity=2
actual_high_lag_7=1
ecmwf=1
jma=1
icon=1
parameters:
[boosting: gbdt]
[objective: regression]
[metric: l1]
[tree_learner: serial]
[device_type: cpu]
[data_sample_strategy: bagging]
[data: ]
[valid: ]
[num_iterations: 20]
[learning_rate: 0.05]
[num_leaves: 15]
[num_threads: 0]
[seed: 42]
[deterministic: 0]
[force_col_wise: 0]
[force_row_wise: 0]
[histogram_pool_size: -1]
[max_depth: -1]
[min_data_in_leaf: 4]
[min_sum_hessian_in_leaf: 0.001]
[bagging_fraction: 0.9]
[pos_bagging_fraction: 1]
[neg_bagging_fraction: 1]
[bagging_freq: 1]
[bagging_seed: 400]
[bagging_by_query: 0]
[feature_fraction: 0.9]
[feature_fraction_bynode: 1]
[feature_fraction_seed: 30056]
[extra_trees: 0]
[extra_seed: 12879]
[early_stopping_round: 0]
[early_stopping_min_delta: 0]
[first_metric_only: 0]
[max_delta_step: 0]
[lambda_l1: 0]
[lambda_l2: 0]
[linear_lambda: 0]
[min_gain_to_split: 0]
[drop_rate: 0.1]
[max_drop: 50]
[skip_drop: 0.5]
[xgboost_dart_mode: 0]
[uniform_drop: 0]
[drop_seed: 17869]
[top_rate: 0.2]
[other_rate: 0.1]
[min_data_per_group: 100]
[max_cat_threshold: 32]
[cat_l2: 10]
[cat_smooth: 10]
[max_cat_to_onehot: 4]
[top_k: 20]
[monotone_constraints: ]
[monotone_constraints_method: basic]
[monotone_penalty: 0]
[feature_contri: ]
[forcedsplits_filename: ]
[refit_decay_rate: 0.9]
[cegb_tradeoff: 1]
[cegb_penalty_split: 0]
[cegb_penalty_feature_lazy: ]
[cegb_penalty_feature_coupled: ]
[path_smooth: 0]
[interaction_constraints: ]
[verbosity: -1]
[saved_feature_importance_type: 0]
[use_quantized_grad: 0]
[num_grad_quant_bins: 4]
[quant_train_renew_leaf: 0]
[stochastic_rounding: 1]
[linear_tree: 0]
[max_bin: 255]
[max_bin_by_feature: ]
[min_data_in_bin: 3]
[bin_construct_sample_cnt: 200000]
[data_random_seed: 175]
[is_enable_sparse: 1]
[enable_bundle: 1]
[use_missing: 1]
[zero_as_missing: 0]
[feature_pre_filter: 1]
[pre_partition: 0]
[two_round: 0]
[header: 0]
[label_column: ]
[weight_column: ]
[group_column: ]
[ignore_column: ]
[categorical_feature: ]
[forcedbins_filename: ]
[precise_float_parser: 0]
[parser_config_file: ]
[objective_seed: 16083]
[num_class: 1]
[is_unbalance: 0]
[scale_pos_weight: 1]
[sigmoid: 1]
[boost_from_average: 1]
[reg_sqrt: 0]
[alpha: 0.9]
[fair_c: 1]
[poisson_max_delta_step: 0.7]
[tweedie_variance_power: 1.5]
[lambdarank_truncation_level: 30]
[lambdarank_norm: 1]
[label_gain: ]
[lambdarank_position_bias_regularization: 0]
[eval_at: ]
[multi_error_top_k: 1]
[auc_mu_weights: ]
[num_machines: 1]
[local_listen_port: 12400]
[time_out: 120]
[machine_list_filename: ]
[machines: ]
[gpu_platform_id: -1]
[gpu_device_id: -1]
[gpu_use_dp: 0]
[num_gpu: 1]
end of parameters
pandas_categorical:null
@@ -0,0 +1,66 @@
{
"model_type": "LightGBMRegressor",
"target": "actual_high",
"horizon": "D0",
"feature_names": [
"actual_high_lag_1",
"actual_high_lag_2",
"actual_high_lag_3",
"actual_high_lag_7",
"actual_high_mean_7",
"actual_high_mean_14",
"actual_high_trend_3",
"open_meteo",
"ecmwf",
"gfs",
"gem",
"jma",
"icon",
"mgm",
"nws",
"deb_prediction",
"model_median",
"model_spread",
"current_temp",
"max_so_far",
"humidity",
"wind_speed_kt",
"visibility_mi",
"local_hour",
"month",
"weekday",
"peak_status_code"
],
"base_model_columns": [
"open_meteo",
"ecmwf",
"gfs",
"gem",
"jma",
"icon",
"mgm",
"nws"
],
"model_path": "artifacts\\models\\lgbm_daily_high.txt",
"sample_count": 18,
"train_count": 6,
"validation_count": 12,
"metrics": {
"validation": {
"sample_count": 12,
"lgbm_mae": 7.992,
"deb_mae": 2.375,
"best_single_mae": 1.05,
"median_mae": 2.292
},
"full_sample": {
"sample_count": 18,
"lgbm_mae": 3.034,
"deb_mae": 1.689,
"best_single_mae": 0.822,
"median_mae": 1.711
}
},
"generated_at": "2026-04-15T16:41:56.814421Z",
"trained_at": "2026-04-15T16:41:56.814421Z"
}
+63 -128
View File
@@ -1,20 +1,20 @@
{
"version": "emos-20260320132525",
"trained_at": "2026-03-20T13:25:25.836021+00:00",
"version": "emos-20260415170349",
"trained_at": "2026-04-15T17:03:49.122865+00:00",
"global": {
"mu": {
"intercept": -1.57406048,
"raw_mu_coef": 2.80583627,
"deb_coef": -0.06819634,
"ens_median_coef": -1.81560215,
"max_so_far_gap_coef": 0.0
"intercept": 2.45853035,
"raw_mu_coef": 2.03689031,
"deb_coef": -1.41207502,
"ens_median_coef": 0.08616811,
"max_so_far_gap_coef": -0.5886898
},
"sigma": {
"intercept": 0.67509915,
"raw_sigma_coef": 0.14431833,
"spread_coef": 0.14431833,
"peak_flag_coef": 0.0,
"max_so_far_gap_coef": 0.0
"intercept": 0.61260118,
"raw_sigma_coef": -1.48747774,
"spread_coef": 2.24562634,
"peak_flag_coef": -0.15906747,
"max_so_far_gap_coef": -0.01835251
}
},
"sigma_constraints": {
@@ -29,146 +29,81 @@
"max_bucket_brier_increase": 0.05
},
"blending": {
"alpha_mu": 0.0,
"alpha_sigma": 0.0
"alpha_mu": 0.05,
"alpha_sigma": 1.0
},
"cities": {
"tokyo": {
"samples": 5,
"mu_bias": 2.223946,
"sigma_scale": 2.0,
"confidence": 0.625
},
"ankara": {
"samples": 7,
"mu_bias": 0.566273,
"sigma_scale": 2.0,
"confidence": 0.875
},
"london": {
"samples": 6,
"mu_bias": 0.489961,
"sigma_scale": 2.0,
"mu_bias": -0.396953,
"sigma_scale": 1.094474,
"confidence": 0.75
},
"new york": {
"samples": 4,
"mu_bias": 1.852451,
"sigma_scale": 0.830444,
"confidence": 0.5
},
"paris": {
"samples": 7,
"mu_bias": 0.308599,
"sigma_scale": 2.0,
"confidence": 0.875
},
"seoul": {
"samples": 6,
"mu_bias": -1.486024,
"sigma_scale": 1.569585,
"confidence": 0.75
},
"toronto": {
"samples": 5,
"mu_bias": -0.734395,
"sigma_scale": 1.656751,
"confidence": 0.625
},
"buenos aires": {
"samples": 5,
"mu_bias": -1.753334,
"sigma_scale": 1.831739,
"confidence": 0.625
},
"wellington": {
"samples": 6,
"mu_bias": 0.350757,
"sigma_scale": 1.377974,
"confidence": 0.75
},
"chicago": {
"samples": 4,
"mu_bias": 3.01062,
"sigma_scale": 0.825575,
"confidence": 0.5
},
"sao paulo": {
"samples": 5,
"mu_bias": 1.632457,
"sigma_scale": 2.0,
"confidence": 0.625
},
"dallas": {
"samples": 4,
"mu_bias": 3.77714,
"sigma_scale": 0.796874,
"confidence": 0.5
},
"miami": {
"samples": 5,
"mu_bias": -4.868741,
"sigma_scale": 2.0,
"confidence": 0.625
},
"atlanta": {
"samples": 5,
"mu_bias": -7.648823,
"sigma_scale": 2.0,
"confidence": 0.625
},
"seattle": {
"samples": 4,
"mu_bias": 4.058619,
"sigma_scale": 2.0,
"confidence": 0.5
},
"lucknow": {
"samples": 4,
"mu_bias": 3.257609,
"sigma_scale": 2.0,
"confidence": 0.5
},
"munich": {
"samples": 6,
"mu_bias": -0.780811,
"sigma_scale": 2.0,
"confidence": 0.75
},
"hong kong": {
"istanbul": {
"samples": 3,
"mu_bias": -0.492675,
"sigma_scale": 2.0,
"mu_bias": -0.163153,
"sigma_scale": 0.5,
"confidence": 0.375
},
"hong kong": {
"samples": 7,
"mu_bias": 3.267553,
"sigma_scale": 2.0,
"confidence": 0.875
},
"taipei": {
"samples": 5,
"mu_bias": 4.80044,
"sigma_scale": 2.0,
"confidence": 0.625
},
"seoul": {
"samples": 3,
"mu_bias": 1.462462,
"sigma_scale": 1.132834,
"mu_bias": -3.080962,
"sigma_scale": 1.437353,
"confidence": 0.375
},
"milan": {
"samples": 3,
"mu_bias": -2.729203,
"mu_bias": -4.20255,
"sigma_scale": 2.0,
"confidence": 0.375
},
"shanghai": {
"samples": 3,
"mu_bias": 1.185335,
"sigma_scale": 1.68386,
"confidence": 0.375
},
"warsaw": {
"samples": 3,
"mu_bias": 0.349319,
"sigma_scale": 1.337949,
"mu_bias": -1.574246,
"sigma_scale": 1.343402,
"confidence": 0.375
}
},
"metrics": {
"sample_count": 105,
"mean_crps": 2.923823,
"legacy_mean_crps": 2.793938,
"legacy_mean_mae": 2.721143,
"legacy_bucket_hit_rate": 0.695238,
"legacy_bucket_brier": 0.775463,
"selected_mean_crps": 2.700275,
"selected_mean_mae": 2.721143,
"selected_bucket_hit_rate": 0.695238,
"selected_bucket_brier": 0.765459,
"selected_score": 4.003626,
"legacy_score": 4.104792,
"filled_actual_from_history": 2,
"settlement_history_city_count": 30
"sample_count": 71,
"mean_crps": 3.226613,
"legacy_mean_crps": 3.608885,
"legacy_mean_mae": 3.81507,
"legacy_bucket_hit_rate": 0.492958,
"legacy_bucket_brier": 0.873776,
"selected_mean_crps": 3.44742,
"selected_mean_mae": 3.749078,
"selected_bucket_hit_rate": 0.492958,
"selected_bucket_brier": 0.878621,
"selected_score": 5.241857,
"legacy_score": 5.406287,
"filled_actual_from_history": 0,
"settlement_history_city_count": 30,
"legacy_archive_samples": 54
},
"source": "artifacts\\probability_calibration\\default.json"
}
@@ -1,248 +1,284 @@
{
"summary": {
"sample_count": 105,
"filled_actual_from_history": 2,
"sample_count": 71,
"filled_actual_from_history": 0,
"legacy": {
"mean_crps": 2.793938,
"mean_mae": 2.721143,
"bucket_hit_rate": 0.695238
"mean_crps": 3.608885,
"mean_mae": 3.81507,
"bucket_hit_rate": 0.492958
},
"emos": {
"mean_crps": 2.700275,
"mean_mae": 2.721143,
"bucket_hit_rate": 0.695238
"mean_crps": 3.881798,
"mean_mae": 4.378282,
"bucket_hit_rate": 0.225352
},
"delta": {
"crps": -0.093663,
"mae": 0.0,
"bucket_hit_rate": 0.0
"crps": 0.272913,
"mae": 0.563211,
"bucket_hit_rate": -0.267606
}
},
"by_city": {
"ankara": {
"samples": 7,
"legacy_mean_crps": 2.023242,
"emos_mean_crps": 2.023242,
"legacy_mean_mae": 1.984286,
"emos_mean_mae": 1.984286,
"legacy_bucket_hit_rate": 0.714286,
"emos_bucket_hit_rate": 0.714286
"samples": 6,
"legacy_mean_crps": 1.365135,
"emos_mean_crps": 1.454394,
"legacy_mean_mae": 1.466667,
"emos_mean_mae": 1.781865,
"legacy_bucket_hit_rate": 0.666667,
"emos_bucket_hit_rate": 0.333333
},
"atlanta": {
"samples": 5,
"legacy_mean_crps": 12.792034,
"emos_mean_crps": 12.694543,
"legacy_mean_mae": 12.806,
"emos_mean_mae": 12.806,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
"samples": 2,
"legacy_mean_crps": 30.449382,
"emos_mean_crps": 30.2136,
"legacy_mean_mae": 32.015,
"emos_mean_mae": 31.431544,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"buenos aires": {
"samples": 5,
"legacy_mean_crps": 3.846144,
"emos_mean_crps": 3.846144,
"legacy_mean_mae": 4.168,
"emos_mean_mae": 4.168,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
"samples": 2,
"legacy_mean_crps": 9.113412,
"emos_mean_crps": 9.25091,
"legacy_mean_mae": 10.27,
"emos_mean_mae": 10.234011,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"busan": {
"samples": 1,
"legacy_mean_crps": 0.451701,
"emos_mean_crps": 1.176676,
"legacy_mean_mae": 0.3,
"emos_mean_mae": 1.887548,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 0.0
},
"chengdu": {
"samples": 1,
"legacy_mean_crps": 0.306469,
"emos_mean_crps": 0.555327,
"legacy_mean_mae": 0.3,
"emos_mean_mae": 0.929502,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 0.0
},
"chicago": {
"samples": 4,
"legacy_mean_crps": 1.346667,
"emos_mean_crps": 0.601765,
"samples": 1,
"legacy_mean_crps": 1.250268,
"emos_mean_crps": 2.090175,
"legacy_mean_mae": 0.0,
"emos_mean_mae": 0.0,
"emos_mean_mae": 3.396557,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"emos_bucket_hit_rate": 0.0
},
"dallas": {
"samples": 4,
"legacy_mean_crps": 1.256111,
"emos_mean_crps": 0.651425,
"samples": 1,
"legacy_mean_crps": 2.173363,
"emos_mean_crps": 1.05572,
"legacy_mean_mae": 0.0,
"emos_mean_mae": 0.0,
"emos_mean_mae": 1.653475,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"emos_bucket_hit_rate": 0.0
},
"hong kong": {
"samples": 7,
"legacy_mean_crps": 0.706099,
"emos_mean_crps": 1.573367,
"legacy_mean_mae": 0.657143,
"emos_mean_mae": 1.88803,
"legacy_bucket_hit_rate": 0.714286,
"emos_bucket_hit_rate": 0.142857
},
"istanbul": {
"samples": 3,
"legacy_mean_crps": 0.261027,
"emos_mean_crps": 0.261027,
"legacy_mean_mae": 0.1,
"emos_mean_mae": 0.1,
"legacy_bucket_hit_rate": 1.0,
"legacy_mean_crps": 1.264103,
"emos_mean_crps": 1.266844,
"legacy_mean_mae": 1.5,
"emos_mean_mae": 1.415758,
"legacy_bucket_hit_rate": 0.333333,
"emos_bucket_hit_rate": 0.666667
},
"london": {
"samples": 6,
"legacy_mean_crps": 2.079624,
"emos_mean_crps": 2.079624,
"legacy_mean_mae": 2.451667,
"emos_mean_mae": 2.451667,
"legacy_bucket_hit_rate": 0.166667,
"emos_bucket_hit_rate": 0.166667
"samples": 2,
"legacy_mean_crps": 3.885033,
"emos_mean_crps": 4.057521,
"legacy_mean_mae": 4.135,
"emos_mean_mae": 4.313103,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"lucknow": {
"samples": 4,
"legacy_mean_crps": 1.468528,
"emos_mean_crps": 1.468528,
"legacy_mean_mae": 1.6025,
"emos_mean_mae": 1.6025,
"legacy_bucket_hit_rate": 0.5,
"samples": 2,
"legacy_mean_crps": 2.487193,
"emos_mean_crps": 2.021264,
"legacy_mean_mae": 3.205,
"emos_mean_mae": 2.47462,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.5
},
"madrid": {
"samples": 2,
"legacy_mean_crps": 6.27726,
"emos_mean_crps": 6.27726,
"emos_mean_crps": 6.588692,
"legacy_mean_mae": 7.33,
"emos_mean_mae": 7.33,
"emos_mean_mae": 7.486713,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"miami": {
"samples": 5,
"legacy_mean_crps": 11.665378,
"emos_mean_crps": 11.665378,
"legacy_mean_mae": 12.07,
"emos_mean_mae": 12.07,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
"samples": 2,
"legacy_mean_crps": 28.637631,
"emos_mean_crps": 28.800796,
"legacy_mean_mae": 30.175,
"emos_mean_mae": 30.107593,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"milan": {
"samples": 3,
"legacy_mean_crps": 4.401392,
"emos_mean_crps": 3.928883,
"emos_mean_crps": 4.947416,
"legacy_mean_mae": 4.06,
"emos_mean_mae": 4.06,
"emos_mean_mae": 5.6958,
"legacy_bucket_hit_rate": 0.666667,
"emos_bucket_hit_rate": 0.666667
"emos_bucket_hit_rate": 0.333333
},
"munich": {
"samples": 6,
"legacy_mean_crps": 2.988583,
"emos_mean_crps": 2.988583,
"legacy_mean_mae": 3.143333,
"emos_mean_mae": 3.143333,
"samples": 2,
"legacy_mean_crps": 3.145192,
"emos_mean_crps": 3.200946,
"legacy_mean_mae": 3.64,
"emos_mean_mae": 3.634475,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"new york": {
"samples": 1,
"legacy_mean_crps": 3.692845,
"emos_mean_crps": 3.881457,
"legacy_mean_mae": 4.94,
"emos_mean_mae": 4.954702,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"paris": {
"samples": 2,
"legacy_mean_crps": 4.013782,
"emos_mean_crps": 4.130614,
"legacy_mean_mae": 4.265,
"emos_mean_mae": 4.314216,
"legacy_bucket_hit_rate": 0.5,
"emos_bucket_hit_rate": 0.5
},
"new york": {
"samples": 4,
"legacy_mean_crps": 1.861101,
"emos_mean_crps": 1.409393,
"legacy_mean_mae": 1.3725,
"emos_mean_mae": 1.3725,
"legacy_bucket_hit_rate": 0.75,
"emos_bucket_hit_rate": 0.75
},
"paris": {
"samples": 7,
"legacy_mean_crps": 2.430082,
"emos_mean_crps": 2.430082,
"legacy_mean_mae": 2.518571,
"emos_mean_mae": 2.518571,
"legacy_bucket_hit_rate": 0.571429,
"emos_bucket_hit_rate": 0.571429
},
"sao paulo": {
"samples": 5,
"legacy_mean_crps": 2.454756,
"emos_mean_crps": 2.454756,
"legacy_mean_mae": 2.628,
"emos_mean_mae": 2.628,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
"samples": 2,
"legacy_mean_crps": 5.540967,
"emos_mean_crps": 5.483258,
"legacy_mean_mae": 6.57,
"emos_mean_mae": 6.358434,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"seattle": {
"samples": 4,
"legacy_mean_crps": 0.531656,
"emos_mean_crps": 0.452784,
"samples": 1,
"legacy_mean_crps": 0.315488,
"emos_mean_crps": 0.271998,
"legacy_mean_mae": 0.0,
"emos_mean_mae": 0.0,
"emos_mean_mae": 0.105037,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"seoul": {
"samples": 6,
"legacy_mean_crps": 0.328088,
"emos_mean_crps": 0.328088,
"samples": 3,
"legacy_mean_crps": 0.508331,
"emos_mean_crps": 1.718042,
"legacy_mean_mae": 0.2,
"emos_mean_mae": 0.2,
"emos_mean_mae": 2.57121,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"emos_bucket_hit_rate": 0.0
},
"shanghai": {
"samples": 2,
"legacy_mean_crps": 0.250034,
"emos_mean_crps": 0.250034,
"legacy_mean_mae": 0.15,
"emos_mean_mae": 0.15,
"samples": 3,
"legacy_mean_crps": 0.299116,
"emos_mean_crps": 0.553444,
"legacy_mean_mae": 0.1,
"emos_mean_mae": 0.71746,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"emos_bucket_hit_rate": 0.666667
},
"shenzhen": {
"samples": 1,
"legacy_mean_crps": 1.198351,
"emos_mean_crps": 0.507942,
"legacy_mean_mae": 1.3,
"emos_mean_mae": 0.644561,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"singapore": {
"samples": 2,
"legacy_mean_crps": 0.281993,
"emos_mean_crps": 0.281993,
"emos_mean_crps": 0.877023,
"legacy_mean_mae": 0.15,
"emos_mean_mae": 0.15,
"emos_mean_mae": 1.142765,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"emos_bucket_hit_rate": 0.5
},
"taipei": {
"samples": 3,
"legacy_mean_crps": 0.356996,
"emos_mean_crps": 0.356996,
"legacy_mean_mae": 0.1,
"emos_mean_mae": 0.1,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"samples": 5,
"legacy_mean_crps": 0.950739,
"emos_mean_crps": 1.257972,
"legacy_mean_mae": 0.94,
"emos_mean_mae": 1.687014,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.2
},
"tel aviv": {
"samples": 2,
"legacy_mean_crps": 0.446758,
"emos_mean_crps": 0.446758,
"emos_mean_crps": 0.482341,
"legacy_mean_mae": 0.3,
"emos_mean_mae": 0.3,
"emos_mean_mae": 0.62659,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"emos_bucket_hit_rate": 0.5
},
"tokyo": {
"samples": 2,
"legacy_mean_crps": 0.450128,
"emos_mean_crps": 0.450128,
"legacy_mean_mae": 0.25,
"emos_mean_mae": 0.25,
"legacy_bucket_hit_rate": 0.5,
"emos_bucket_hit_rate": 1.0
"samples": 5,
"legacy_mean_crps": 0.879366,
"emos_mean_crps": 0.989336,
"legacy_mean_mae": 1.022,
"emos_mean_mae": 1.450474,
"legacy_bucket_hit_rate": 0.2,
"emos_bucket_hit_rate": 0.2
},
"toronto": {
"samples": 5,
"legacy_mean_crps": 2.647861,
"emos_mean_crps": 2.566068,
"legacy_mean_mae": 2.532,
"emos_mean_mae": 2.532,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
"samples": 2,
"legacy_mean_crps": 5.497916,
"emos_mean_crps": 6.296632,
"legacy_mean_mae": 6.33,
"emos_mean_mae": 7.003984,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"warsaw": {
"samples": 3,
"legacy_mean_crps": 1.618875,
"emos_mean_crps": 1.618875,
"emos_mean_crps": 1.649515,
"legacy_mean_mae": 2.056667,
"emos_mean_mae": 2.056667,
"emos_mean_mae": 2.073494,
"legacy_bucket_hit_rate": 0.333333,
"emos_bucket_hit_rate": 0.333333
"emos_bucket_hit_rate": 0.0
},
"wellington": {
"samples": 6,
"legacy_mean_crps": 0.266349,
"emos_mean_crps": 0.266349,
"legacy_mean_mae": 0.2,
"emos_mean_mae": 0.2,
"samples": 2,
"legacy_mean_crps": 0.364919,
"emos_mean_crps": 0.939463,
"legacy_mean_mae": 0.15,
"emos_mean_mae": 1.007263,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"emos_bucket_hit_rate": 0.5
}
}
}
@@ -17,55 +17,57 @@
"max_delta_bucket_brier_observe": 0.15
},
"evaluation": {
"sample_count": 105,
"delta_crps": -0.093663,
"sample_count": 54,
"delta_crps": -0.086732,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0
},
"shadow": {
"sample_count": 103,
"delta_mae": 0.012708,
"delta_bucket_hit_rate": 0.009709,
"delta_bucket_brier": 0.293835
"sample_count": 48,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.041666,
"delta_bucket_brier": 0.123252
},
"blocking_reasons": [
"shadow bucket brier 退化超限:delta=0.293835"
"离线评估样本不足:54 < 80",
"shadow 样本不足:48 < 50",
"shadow bucket brier 退化超限:delta=0.123252"
],
"worst_shadow_regressions": [
{
"city": "dallas",
"samples": 4,
"delta_mae": 0.114807,
"samples": 1,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.778678
"delta_bucket_brier": 0.792585
},
{
"city": "chicago",
"samples": 4,
"delta_mae": 0.075265,
"samples": 1,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.746156
"delta_bucket_brier": 0.791878
},
{
"city": "seattle",
"samples": 4,
"delta_mae": 0.11262,
"samples": 1,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.692003
"delta_bucket_brier": 0.61609
},
{
"city": "atlanta",
"samples": 4,
"delta_mae": 0.293028,
"delta_bucket_hit_rate": -0.25,
"delta_bucket_brier": 0.601425
"city": "wellington",
"samples": 2,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.509203
},
{
"city": "miami",
"samples": 4,
"delta_mae": 0.241559,
"delta_bucket_hit_rate": -0.5,
"delta_bucket_brier": 0.478245
"city": "tel aviv",
"samples": 2,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.439879
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5
View File
@@ -81,6 +81,11 @@ cities:
country: China
latitude: 22.6393
longitude: 113.8107
- id: guangzhou
city: Guangzhou
country: China
latitude: 23.3924
longitude: 113.2988
- id: beijing
city: Beijing
country: China
+1 -1
View File
@@ -1,4 +1,4 @@
// SPDX-License-Identifier: MIT
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.24;
interface IERC20 {
+1 -1
View File
@@ -1,4 +1,4 @@
// SPDX-License-Identifier: MIT
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.24;
interface IERC20 {
+70
View File
@@ -116,3 +116,73 @@
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "hong kong", "timestamp": "2026-03-23T21:10:00+08:00", "date": "2026-03-23", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.6806250000000001, "deb_prediction": 25.2, "ensemble": {"p10": 26.3, "median": 26.4, "p90": 26.6}, "multi_model": {"Open-Meteo": 24.8, "HKO(港天文)": 27.0, "ECMWF": 25.4, "GFS": 25.1, "ICON": 24.8, "GEM": 25.3, "JMA": 23.6}, "max_so_far": 27.4, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "shenzhen", "timestamp": "2026-03-25T08:43:15.528748+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 29.0, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "shenzhen", "timestamp": "2026-03-25T08:57:11.783182+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": 26.7, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 26.7, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [{"v": 27, "p": 1.0}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 26.7, "calibrated_sigma": 0.24322631835937514}
{"city": "shenzhen", "timestamp": "2026-03-25T09:32:32+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.16637912326388898, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 28.9, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "shenzhen", "timestamp": "2026-03-25T10:02:35+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.15911458333333342, "deb_prediction": 28.2, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.5, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.5, "GEM": 30.7, "JMA": 26.2}, "max_so_far": 28.9, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "shanghai", "timestamp": "2026-03-29T15:00:00.000Z", "date": "2026-03-29", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.23736458333333335, "deb_prediction": 18.4, "ensemble": {"p10": 16.1, "median": 16.5, "p90": 16.9}, "multi_model": {"Open-Meteo": 17.0, "ECMWF": 19.2, "GFS": 19.1, "ICON": 17.0, "GEM": 17.6, "JMA": 15.8}, "max_so_far": 18.0, "observation": {"current_temp": 14.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": 3.11, "local_hour": 23.25}, "peak_status": "past", "prob_snapshot": [{"v": 18, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "ankara", "timestamp": "2026-03-29T15:01:00.000Z", "date": "2026-03-29", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.25176666666666664, "deb_prediction": 9.7, "ensemble": {"p10": 9.2, "median": 9.5, "p90": 10.2}, "multi_model": {"Open-Meteo": 9.2, "ECMWF": 9.5, "GFS": 10.1, "ICON": 9.2, "GEM": 11.0, "JMA": 10.0}, "max_so_far": 10.0, "observation": {"current_temp": 7.0, "humidity": null, "wind_speed_kt": 12.0, "visibility_mi": null, "local_hour": 18.25}, "peak_status": "past", "prob_snapshot": [{"v": 10, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "chengdu", "timestamp": "2026-04-08T07:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 24.3, "raw_sigma": 1.1821289062499998, "deb_prediction": 23.1, "ensemble": {"p10": 21.8, "median": 23.0, "p90": 24.5}, "multi_model": {"Open-Meteo": 22.5, "ECMWF": 23.9, "GFS": 23.0, "ICON": 22.5, "GEM": 23.7, "JMA": 23.2}, "max_so_far": 24.0, "observation": {"current_temp": 24.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 15.183333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 24, "p": 0.442}, {"v": 25, "p": 0.386}, {"v": 26, "p": 0.172}], "shadow_prob_snapshot": [{"v": 24, "p": 0.394}, {"v": 25, "p": 0.355}, {"v": 26, "p": 0.19}, {"v": 27, "p": 0.06}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 24.3, "calibrated_sigma": 1.354078466151897}
{"city": "chengdu", "timestamp": "2026-04-08T08:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 24.3, "raw_sigma": 0.7283767361111106, "deb_prediction": 23.1, "ensemble": {"p10": 21.8, "median": 22.9, "p90": 24.2}, "multi_model": {"Open-Meteo": 22.5, "ECMWF": 23.9, "GFS": 22.5, "ICON": 22.5, "GEM": 23.7, "JMA": 23.2}, "max_so_far": 24.0, "observation": {"current_temp": 23.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 16.133333333333333}, "peak_status": "in_window", "prob_snapshot": [{"v": 24, "p": 0.547}, {"v": 25, "p": 0.397}, {"v": 26, "p": 0.056}], "shadow_prob_snapshot": [{"v": 24, "p": 0.521}, {"v": 25, "p": 0.399}, {"v": 26, "p": 0.08}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 24.3, "calibrated_sigma": 0.8140063140878262}
{"city": "tokyo", "timestamp": "2026-04-08T08:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 17.810000000000002, "raw_sigma": 0.23200683593750038, "deb_prediction": 17.1, "ensemble": {"p10": 17.4, "median": 18.3, "p90": 19.1}, "multi_model": {"Open-Meteo": 16.1, "ECMWF": 16.3, "GFS": 17.6, "ICON": 18.2, "GEM": 18.3, "JMA": 16.1}, "max_so_far": 17.0, "observation": {"current_temp": 16.0, "humidity": null, "wind_speed_kt": 17.0, "visibility_mi": null, "local_hour": 17.133333333333333}, "peak_status": "past", "prob_snapshot": [{"v": 18, "p": 0.909}, {"v": 17, "p": 0.091}], "shadow_prob_snapshot": [{"v": 18, "p": 0.892}, {"v": 17, "p": 0.108}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 17.810000000000002, "calibrated_sigma": 0.25}
{"city": "ankara", "timestamp": "2026-04-11T11:20:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 0.5468749999999998, "deb_prediction": 7.9, "ensemble": {"p10": 6.8, "median": 7.4, "p90": 8.2}, "multi_model": {"Open-Meteo": 7.5, "ECMWF": 7.5, "GFS": 8.3, "ICON": 7.5, "GEM": 8.2, "JMA": 8.3}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 14.45}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.615}, {"v": 9, "p": 0.37}, {"v": 10, "p": 0.015}], "shadow_prob_snapshot": [{"v": 8, "p": 0.594}, {"v": 9, "p": 0.381}, {"v": 10, "p": 0.024}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 0.5978357923824302}
{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 0.42968750000000056, "deb_prediction": 10.8, "ensemble": {"p10": 9.7, "median": 10.4, "p90": 10.8}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.3, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.45}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.671}, {"v": 12, "p": 0.329}], "shadow_prob_snapshot": [{"v": 11, "p": 0.654}, {"v": 12, "p": 0.346}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 0.46787549747087}
{"city": "hong kong", "timestamp": "2026-04-11T19:10:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.10084635416666676, "deb_prediction": 27.3, "ensemble": {"p10": 27.0, "median": 27.3, "p90": 27.8}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 4.3, "visibility_mi": null, "local_hour": 19.45}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.839}, {"v": 28, "p": 0.161}], "shadow_prob_snapshot": [{"v": 27, "p": 0.769}, {"v": 28, "p": 0.231}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.13614257812500014}
{"city": "taipei", "timestamp": "2026-04-11T11:00:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10876464843750025, "deb_prediction": 29.5, "ensemble": {"p10": 28.9, "median": 29.4, "p90": 29.8}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 2.0, "visibility_mi": null, "local_hour": 19.45}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.1, "raw_sigma": 0.8500000000000005, "deb_prediction": 10.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.8, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.85}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.582}, {"v": 12, "p": 0.355}, {"v": 13, "p": 0.063}], "shadow_prob_snapshot": [{"v": 11, "p": 0.563}, {"v": 12, "p": 0.361}, {"v": 13, "p": 0.076}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.1, "calibrated_sigma": 0.9012761535978395}
{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 0.42968750000000056, "deb_prediction": 10.9, "ensemble": {"p10": 9.7, "median": 10.4, "p90": 10.8}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.8, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.85}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.671}, {"v": 12, "p": 0.329}], "shadow_prob_snapshot": [{"v": 11, "p": 0.656}, {"v": 12, "p": 0.344}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 0.4631927411757732}
{"city": "hong kong", "timestamp": "2026-04-11T19:40:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.375, "deb_prediction": 27.3, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 2.7, "visibility_mi": null, "local_hour": 19.85}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.603}, {"v": 28, "p": 0.397}], "shadow_prob_snapshot": [{"v": 27, "p": 0.597}, {"v": 28, "p": 0.403}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.39311002429138}
{"city": "hong kong", "timestamp": "2026-04-11T19:40:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.09750000000000009, "deb_prediction": 27.3, "ensemble": {"p10": 27.0, "median": 27.3, "p90": 27.8}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 2.7, "visibility_mi": null, "local_hour": 19.85}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.847}, {"v": 28, "p": 0.153}], "shadow_prob_snapshot": [{"v": 27, "p": 0.776}, {"v": 28, "p": 0.224}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.13162500000000013}
{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.7199999999999995, "deb_prediction": 29.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 19.866666666666667}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10938964843750025, "deb_prediction": 29.5, "ensemble": {"p10": 28.9, "median": 29.4, "p90": 29.8}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 19.866666666666667}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "ankara", "timestamp": "2026-04-11T11:20:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 1.0, "deb_prediction": 7.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 7.5}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 14.883333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.475}, {"v": 9, "p": 0.395}, {"v": 10, "p": 0.131}], "shadow_prob_snapshot": [{"v": 8, "p": 0.451}, {"v": 9, "p": 0.389}, {"v": 10, "p": 0.16}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 1.1241186693749348}
{"city": "ankara", "timestamp": "2026-04-11T11:20:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 0.5468749999999998, "deb_prediction": 7.9, "ensemble": {"p10": 6.8, "median": 7.4, "p90": 8.2}, "multi_model": {"Open-Meteo": 7.5, "ECMWF": 7.5, "GFS": 8.2, "ICON": 7.5, "GEM": 8.2, "JMA": 8.3}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 14.9}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.615}, {"v": 9, "p": 0.37}, {"v": 10, "p": 0.015}], "shadow_prob_snapshot": [{"v": 8, "p": 0.594}, {"v": 9, "p": 0.381}, {"v": 10, "p": 0.024}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 0.5978357923824302}
{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 1.0, "deb_prediction": 11.3, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 11.3}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.9}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.475}, {"v": 12, "p": 0.395}, {"v": 13, "p": 0.131}], "shadow_prob_snapshot": [{"v": 11, "p": 0.469}, {"v": 12, "p": 0.394}, {"v": 13, "p": 0.137}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 1.0267976073543543}
{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 0.42968750000000056, "deb_prediction": 10.9, "ensemble": {"p10": 9.7, "median": 10.4, "p90": 10.8}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.8, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.9}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.671}, {"v": 12, "p": 0.329}], "shadow_prob_snapshot": [{"v": 11, "p": 0.656}, {"v": 12, "p": 0.344}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 0.4631927411757732}
{"city": "hong kong", "timestamp": "2026-04-11T19:40:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 28.999999999999996, "raw_sigma": 0.3299999999999999, "deb_prediction": 27.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 2.7, "visibility_mi": null, "local_hour": 19.9}, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 0.5}, {"v": 29, "p": 0.5}], "shadow_prob_snapshot": [{"v": 28, "p": 0.5}, {"v": 29, "p": 0.5}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 28.999999999999996, "calibrated_sigma": 0.33392493649594973}
{"city": "hong kong", "timestamp": "2026-04-11T19:40:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.09750000000000009, "deb_prediction": 27.3, "ensemble": {"p10": 27.0, "median": 27.3, "p90": 27.8}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 2.7, "visibility_mi": null, "local_hour": 19.9}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.847}, {"v": 28, "p": 0.153}], "shadow_prob_snapshot": [{"v": 27, "p": 0.776}, {"v": 28, "p": 0.224}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.13162500000000013}
{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.3, "deb_prediction": 27.6, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.6}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 19.9}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10938964843750025, "deb_prediction": 29.5, "ensemble": {"p10": 28.9, "median": 29.4, "p90": 29.8}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 19.9}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "ankara", "timestamp": "2026-04-11T12:00:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 1.0, "deb_prediction": 7.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 7.5}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 5.0, "visibility_mi": null, "local_hour": 14.983333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.475}, {"v": 9, "p": 0.395}, {"v": 10, "p": 0.131}], "shadow_prob_snapshot": [{"v": 8, "p": 0.451}, {"v": 9, "p": 0.389}, {"v": 10, "p": 0.16}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 1.1241186693749348}
{"city": "ankara", "timestamp": "2026-04-11T12:00:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 0.5468749999999998, "deb_prediction": 7.9, "ensemble": {"p10": 6.8, "median": 7.4, "p90": 8.2}, "multi_model": {"Open-Meteo": 7.5, "ECMWF": 7.5, "GFS": 8.2, "ICON": 7.5, "GEM": 8.2, "JMA": 8.3}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 5.0, "visibility_mi": null, "local_hour": 14.983333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.615}, {"v": 9, "p": 0.37}, {"v": 10, "p": 0.015}], "shadow_prob_snapshot": [{"v": 8, "p": 0.594}, {"v": 9, "p": 0.381}, {"v": 10, "p": 0.024}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 0.5978357923824302}
{"city": "istanbul", "timestamp": "2026-04-11T14:50:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 1.0, "deb_prediction": 11.3, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 11.3}, "max_so_far": 11.0, "observation": {"current_temp": 10.0, "humidity": 57.5, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.983333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.475}, {"v": 12, "p": 0.395}, {"v": 13, "p": 0.131}], "shadow_prob_snapshot": [{"v": 11, "p": 0.469}, {"v": 12, "p": 0.394}, {"v": 13, "p": 0.137}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 1.0267976073543543}
{"city": "istanbul", "timestamp": "2026-04-11T14:50:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 0.4494809751157413, "deb_prediction": 10.9, "ensemble": {"p10": 9.7, "median": 10.4, "p90": 10.8}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.8, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 10.0, "humidity": 57.5, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 15.0}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.662}, {"v": 12, "p": 0.338}], "shadow_prob_snapshot": [{"v": 11, "p": 0.647}, {"v": 12, "p": 0.353}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 0.4837415114258855}
{"city": "hong kong", "timestamp": "2026-04-11T19:50:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 28.999999999999996, "raw_sigma": 0.3, "deb_prediction": 29.0, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"HKO(港天文)": 29.0}, "max_so_far": 27.6, "observation": {"current_temp": 26.8, "humidity": 81.0, "wind_speed_kt": 3.2, "visibility_mi": null, "local_hour": 20.0}, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 0.5}, {"v": 29, "p": 0.5}], "shadow_prob_snapshot": [{"v": 28, "p": 0.5}, {"v": 29, "p": 0.5}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 28.999999999999996, "calibrated_sigma": 0.29193323618665046}
{"city": "hong kong", "timestamp": "2026-04-11T19:50:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.09375000000000008, "deb_prediction": 27.4, "ensemble": {"p10": 27.0, "median": 27.3, "p90": 27.8}, "multi_model": {"HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.8, "humidity": 81.0, "wind_speed_kt": 3.2, "visibility_mi": null, "local_hour": 20.0}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.857}, {"v": 28, "p": 0.143}], "shadow_prob_snapshot": [{"v": 27, "p": 0.785}, {"v": 28, "p": 0.215}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.12656250000000013}
{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.3, "deb_prediction": 27.6, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.6}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 20.0}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10938964843750025, "deb_prediction": 29.5, "ensemble": {"p10": 28.9, "median": 29.4, "p90": 29.8}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 20.0}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "taipei", "timestamp": "2026-04-11T19:50:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10938964843749983, "deb_prediction": 30.3, "ensemble": {"p10": 28.8, "median": 29.3, "p90": 29.7}, "multi_model": {"Open-Meteo": 29.7, "ECMWF": 30.9, "GFS": 32.4, "ICON": 29.7, "GEM": 29.6, "JMA": 29.3}, "max_so_far": 32.1, "observation": {"current_temp": 27.6, "humidity": 73.0, "wind_speed_kt": 2.9, "visibility_mi": null, "local_hour": 20.1}, "peak_status": "past", "prob_snapshot": [{"v": 32, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "ankara", "timestamp": "2026-04-12T02:00:00.000Z", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 8.5, "raw_sigma": 1.0, "deb_prediction": 8.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 8.5}, "max_so_far": 2.0, "observation": {"current_temp": 0.0, "humidity": null, "wind_speed_kt": 2.0, "visibility_mi": null, "local_hour": 5.166666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.35}, {"v": 9, "p": 0.35}, {"v": 10, "p": 0.139}, {"v": 7, "p": 0.139}], "shadow_prob_snapshot": [{"v": 9, "p": 0.359}, {"v": 8, "p": 0.359}, {"v": 7, "p": 0.132}, {"v": 10, "p": 0.132}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.5, "calibrated_sigma": 0.955}
{"city": "ankara", "timestamp": "2026-04-12T02:00:00.000Z", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 8.93, "raw_sigma": 0.3515625000000001, "deb_prediction": 9.1, "ensemble": {"p10": 8.5, "median": 9.0, "p90": 9.4}, "multi_model": {"Open-Meteo": 8.5, "ECMWF": 7.9, "GFS": 10.0, "ICON": 8.5, "GEM": 8.9, "JMA": 9.2}, "max_so_far": 2.0, "observation": {"current_temp": 0.0, "humidity": null, "wind_speed_kt": 2.0, "visibility_mi": null, "local_hour": 5.166666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 9, "p": 0.837}, {"v": 8, "p": 0.111}, {"v": 10, "p": 0.052}], "shadow_prob_snapshot": [{"v": 9, "p": 0.851}, {"v": 8, "p": 0.102}, {"v": 10, "p": 0.046}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.93, "calibrated_sigma": 0.3389843750000001}
{"city": "istanbul", "timestamp": "2026-04-12T04:50:00+03:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 11.7, "raw_sigma": 1.0, "deb_prediction": 11.7, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 11.7}, "max_so_far": 8.0, "observation": {"current_temp": 7.0, "humidity": 52.7, "wind_speed_kt": 1.0, "visibility_mi": null, "local_hour": 5.183333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 12, "p": 0.374}, {"v": 11, "p": 0.311}, {"v": 13, "p": 0.179}, {"v": 10, "p": 0.103}], "shadow_prob_snapshot": [{"v": 12, "p": 0.387}, {"v": 11, "p": 0.316}, {"v": 13, "p": 0.174}, {"v": 10, "p": 0.095}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.7, "calibrated_sigma": 0.955}
{"city": "istanbul", "timestamp": "2026-04-12T04:50:00+03:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 11.52, "raw_sigma": 0.390625, "deb_prediction": 11.5, "ensemble": {"p10": 10.5, "median": 11.1, "p90": 11.5}, "multi_model": {"Open-Meteo": 11.7, "ECMWF": 11.9, "GFS": 11.1, "ICON": 11.7, "GEM": 12.8, "JMA": 10.1}, "max_so_far": 8.0, "observation": {"current_temp": 7.0, "humidity": 52.7, "wind_speed_kt": 1.0, "visibility_mi": null, "local_hour": 5.183333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 12, "p": 0.52}, {"v": 11, "p": 0.48}], "shadow_prob_snapshot": [{"v": 12, "p": 0.521}, {"v": 11, "p": 0.479}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.52, "calibrated_sigma": 0.37609375}
{"city": "hong kong", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 28.999999999999996, "raw_sigma": 0.75, "deb_prediction": 28.2, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.5, "HKO(港天文)": 29.0}, "max_so_far": 27.2, "observation": {"current_temp": 27.2, "humidity": 77.0, "wind_speed_kt": 5.4, "visibility_mi": null, "local_hour": 10.2}, "peak_status": "before", "prob_snapshot": [{"v": 28, "p": 0.412}, {"v": 29, "p": 0.412}, {"v": 27, "p": 0.088}, {"v": 30, "p": 0.088}], "shadow_prob_snapshot": [{"v": 28, "p": 0.413}, {"v": 29, "p": 0.413}, {"v": 27, "p": 0.087}, {"v": 30, "p": 0.087}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 28.999999999999996, "calibrated_sigma": 0.7478013193453208}
{"city": "hong kong", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 27.71, "raw_sigma": 0.30937500000000073, "deb_prediction": 27.6, "ensemble": {"p10": 28.1, "median": 28.2, "p90": 28.5}, "multi_model": {"Open-Meteo": 27.5, "HKO(港天文)": 29.0, "ECMWF": 27.0, "GFS": 27.2, "ICON": 27.5, "GEM": 27.1, "JMA": 27.6}, "max_so_far": 27.2, "observation": {"current_temp": 27.2, "humidity": 77.0, "wind_speed_kt": 5.4, "visibility_mi": null, "local_hour": 10.2}, "peak_status": "before", "prob_snapshot": [{"v": 27, "p": 0.824}, {"v": 28, "p": 0.176}], "shadow_prob_snapshot": [{"v": 27, "p": 0.814}, {"v": 28, "p": 0.186}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.71, "calibrated_sigma": 0.32103944874938267}
{"city": "taipei", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 30.9, "raw_sigma": 2.6999999999999993, "deb_prediction": 30.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 30.9}, "max_so_far": 29.2, "observation": {"current_temp": 29.2, "humidity": 64.0, "wind_speed_kt": 1.2, "visibility_mi": null, "local_hour": 10.2}, "peak_status": "before", "prob_snapshot": [{"v": 31, "p": 0.182}, {"v": 30, "p": 0.173}, {"v": 32, "p": 0.168}, {"v": 29, "p": 0.143}], "shadow_prob_snapshot": [{"v": 31, "p": 0.185}, {"v": 30, "p": 0.175}, {"v": 32, "p": 0.17}, {"v": 29, "p": 0.143}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 30.9, "calibrated_sigma": 2.6381596739291915}
{"city": "taipei", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 30.689999999999998, "raw_sigma": 2.8574999999999995, "deb_prediction": 30.9, "ensemble": {"p10": 29.7, "median": 30.2, "p90": 30.6}, "multi_model": {"Open-Meteo": 30.9, "ECMWF": 30.9, "GFS": 32.9, "ICON": 30.9, "GEM": 28.9, "JMA": 30.8}, "max_so_far": 29.2, "observation": {"current_temp": 29.2, "humidity": 64.0, "wind_speed_kt": 1.2, "visibility_mi": null, "local_hour": 10.2}, "peak_status": "before", "prob_snapshot": [{"v": 31, "p": 0.179}, {"v": 30, "p": 0.175}, {"v": 32, "p": 0.163}, {"v": 29, "p": 0.152}], "shadow_prob_snapshot": [{"v": 31, "p": 0.183}, {"v": 30, "p": 0.178}, {"v": 32, "p": 0.165}, {"v": 29, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 30.689999999999998, "calibrated_sigma": 2.778122088606427}
{"city": "ankara", "timestamp": "2026-04-12T02:00:00.000Z", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 8.5, "raw_sigma": 1.0, "deb_prediction": 8.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 8.5}, "max_so_far": 2.0, "observation": {"current_temp": 0.0, "humidity": null, "wind_speed_kt": 2.0, "visibility_mi": null, "local_hour": 5.216666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.35}, {"v": 9, "p": 0.35}, {"v": 10, "p": 0.139}, {"v": 7, "p": 0.139}], "shadow_prob_snapshot": [{"v": 9, "p": 0.359}, {"v": 8, "p": 0.359}, {"v": 7, "p": 0.132}, {"v": 10, "p": 0.132}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.5, "calibrated_sigma": 0.955}
{"city": "istanbul", "timestamp": "2026-04-12T04:50:00+03:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 11.7, "raw_sigma": 1.0, "deb_prediction": 11.7, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 11.7}, "max_so_far": 8.0, "observation": {"current_temp": 7.0, "humidity": 52.7, "wind_speed_kt": 1.0, "visibility_mi": null, "local_hour": 5.233333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 12, "p": 0.374}, {"v": 11, "p": 0.311}, {"v": 13, "p": 0.179}, {"v": 10, "p": 0.103}], "shadow_prob_snapshot": [{"v": 12, "p": 0.387}, {"v": 11, "p": 0.316}, {"v": 13, "p": 0.174}, {"v": 10, "p": 0.095}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.7, "calibrated_sigma": 0.955}
{"city": "ankara", "timestamp": "2026-04-12T02:00:00.000Z", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 8.93, "raw_sigma": 0.3515625000000001, "deb_prediction": 9.1, "ensemble": {"p10": 8.5, "median": 9.0, "p90": 9.4}, "multi_model": {"Open-Meteo": 8.5, "ECMWF": 7.9, "GFS": 10.0, "ICON": 8.5, "GEM": 8.9, "JMA": 9.2}, "max_so_far": 2.0, "observation": {"current_temp": 0.0, "humidity": null, "wind_speed_kt": 2.0, "visibility_mi": null, "local_hour": 5.25}, "peak_status": "before", "prob_snapshot": [{"v": 9, "p": 0.837}, {"v": 8, "p": 0.111}, {"v": 10, "p": 0.052}], "shadow_prob_snapshot": [{"v": 9, "p": 0.851}, {"v": 8, "p": 0.102}, {"v": 10, "p": 0.046}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.93, "calibrated_sigma": 0.3389843750000001}
{"city": "istanbul", "timestamp": "2026-04-12T04:50:00+03:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 11.52, "raw_sigma": 0.390625, "deb_prediction": 11.5, "ensemble": {"p10": 10.5, "median": 11.1, "p90": 11.5}, "multi_model": {"Open-Meteo": 11.7, "ECMWF": 11.9, "GFS": 11.1, "ICON": 11.7, "GEM": 12.8, "JMA": 10.1}, "max_so_far": 8.0, "observation": {"current_temp": 7.0, "humidity": 52.7, "wind_speed_kt": 1.0, "visibility_mi": null, "local_hour": 5.25}, "peak_status": "before", "prob_snapshot": [{"v": 12, "p": 0.52}, {"v": 11, "p": 0.48}], "shadow_prob_snapshot": [{"v": 12, "p": 0.521}, {"v": 11, "p": 0.479}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.52, "calibrated_sigma": 0.37609375}
{"city": "taipei", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 30.9, "raw_sigma": 2.6999999999999993, "deb_prediction": 30.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 30.9}, "max_so_far": 29.2, "observation": {"current_temp": 29.2, "humidity": 64.0, "wind_speed_kt": 1.2, "visibility_mi": null, "local_hour": 10.283333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 31, "p": 0.182}, {"v": 30, "p": 0.173}, {"v": 32, "p": 0.168}, {"v": 29, "p": 0.143}], "shadow_prob_snapshot": [{"v": 31, "p": 0.185}, {"v": 30, "p": 0.175}, {"v": 32, "p": 0.17}, {"v": 29, "p": 0.143}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 30.9, "calibrated_sigma": 2.6381596739291915}
{"city": "hong kong", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 28.999999999999996, "raw_sigma": 0.75, "deb_prediction": 28.2, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.5, "HKO(港天文)": 29.0}, "max_so_far": 27.2, "observation": {"current_temp": 27.2, "humidity": 77.0, "wind_speed_kt": 5.4, "visibility_mi": null, "local_hour": 10.283333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 28, "p": 0.412}, {"v": 29, "p": 0.412}, {"v": 27, "p": 0.088}, {"v": 30, "p": 0.088}], "shadow_prob_snapshot": [{"v": 28, "p": 0.413}, {"v": 29, "p": 0.413}, {"v": 27, "p": 0.087}, {"v": 30, "p": 0.087}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 28.999999999999996, "calibrated_sigma": 0.7478013193453208}
{"city": "taipei", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 30.689999999999998, "raw_sigma": 2.8574999999999995, "deb_prediction": 30.9, "ensemble": {"p10": 29.7, "median": 30.2, "p90": 30.6}, "multi_model": {"Open-Meteo": 30.9, "ECMWF": 30.9, "GFS": 32.9, "ICON": 30.9, "GEM": 28.9, "JMA": 30.8}, "max_so_far": 29.2, "observation": {"current_temp": 29.2, "humidity": 64.0, "wind_speed_kt": 1.2, "visibility_mi": null, "local_hour": 10.283333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 31, "p": 0.179}, {"v": 30, "p": 0.175}, {"v": 32, "p": 0.163}, {"v": 29, "p": 0.152}], "shadow_prob_snapshot": [{"v": 31, "p": 0.183}, {"v": 30, "p": 0.178}, {"v": 32, "p": 0.165}, {"v": 29, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 30.689999999999998, "calibrated_sigma": 2.778122088606427}
{"city": "hong kong", "timestamp": "2026-04-12T10:00:00+08:00", "date": "2026-04-12", "temp_symbol": "°C", "raw_mu": 27.71, "raw_sigma": 0.30937500000000073, "deb_prediction": 27.6, "ensemble": {"p10": 28.1, "median": 28.2, "p90": 28.5}, "multi_model": {"Open-Meteo": 27.5, "HKO(港天文)": 29.0, "ECMWF": 27.0, "GFS": 27.2, "ICON": 27.5, "GEM": 27.1, "JMA": 27.6}, "max_so_far": 27.2, "observation": {"current_temp": 27.2, "humidity": 77.0, "wind_speed_kt": 5.4, "visibility_mi": null, "local_hour": 10.283333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 27, "p": 0.824}, {"v": 28, "p": 0.176}], "shadow_prob_snapshot": [{"v": 27, "p": 0.814}, {"v": 28, "p": 0.186}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.71, "calibrated_sigma": 0.32103944874938267}
{"city": "ankara", "timestamp": "2026-04-13T10:20:00.000Z", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": 10.8, "raw_sigma": 2.5, "deb_prediction": 10.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 10.6, "ECMWF": 11.7, "GFS": 11.3, "ICON": 10.6, "GEM": 10.8, "JMA": 10.5}, "max_so_far": 9.0, "observation": {"current_temp": 9.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 13.566666666666666}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.195}, {"v": 10, "p": 0.186}, {"v": 12, "p": 0.175}, {"v": 9, "p": 0.152}], "shadow_prob_snapshot": [{"v": 11, "p": 0.199}, {"v": 10, "p": 0.189}, {"v": 12, "p": 0.177}, {"v": 9, "p": 0.152}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 10.8, "calibrated_sigma": 2.4191943428283595}
{"city": "ankara", "timestamp": "2026-04-13T10:20:00.000Z", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": 10.77, "raw_sigma": 3.7124999999999995, "deb_prediction": 10.9, "ensemble": {"p10": 9.8, "median": 10.7, "p90": 11.6}, "multi_model": {"Open-Meteo": 10.6, "ECMWF": 11.7, "GFS": 11.3, "ICON": 10.6, "GEM": 10.8, "JMA": 10.5}, "max_so_far": 9.0, "observation": {"current_temp": 9.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 13.583333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.15}, {"v": 10, "p": 0.148}, {"v": 12, "p": 0.143}, {"v": 9, "p": 0.135}], "shadow_prob_snapshot": [{"v": 11, "p": 0.173}, {"v": 10, "p": 0.168}, {"v": 12, "p": 0.16}, {"v": 9, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 10.77, "calibrated_sigma": 3.0}
{"city": "istanbul", "timestamp": "2026-04-13T13:20:00+03:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": 12.5, "raw_sigma": 1.7999999999999998, "deb_prediction": 12.2, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 12.5, "ECMWF": 12.4, "GFS": 12.3, "ICON": 12.5, "GEM": 13.5, "JMA": 10.4}, "max_so_far": 12.0, "observation": {"current_temp": 12.0, "humidity": 46.9, "wind_speed_kt": 14.0, "visibility_mi": null, "local_hour": 13.583333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 12, "p": 0.298}, {"v": 13, "p": 0.298}, {"v": 14, "p": 0.22}, {"v": 15, "p": 0.121}], "shadow_prob_snapshot": [{"v": 12, "p": 0.294}, {"v": 13, "p": 0.294}, {"v": 14, "p": 0.22}, {"v": 15, "p": 0.124}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 12.5, "calibrated_sigma": 1.8402601718515887}
{"city": "hong kong", "timestamp": "2026-04-13T18:20:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.47999999999999987, "deb_prediction": 27.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.5, "humidity": 76.0, "wind_speed_kt": 4.3, "visibility_mi": null, "local_hour": 18.583333333333332}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "hong kong", "timestamp": "2026-04-13T18:20:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10952569444444488, "deb_prediction": 27.5, "ensemble": {"p10": 28.0, "median": 28.3, "p90": 28.6}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.5, "humidity": 76.0, "wind_speed_kt": 4.3, "visibility_mi": null, "local_hour": 18.583333333333332}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "tokyo", "timestamp": "2026-04-13T10:00:00.000Z", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.4349999999999998, "deb_prediction": 21.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 20.3, "ECMWF": 22.8, "GFS": 22.0, "ICON": 22.6, "GEM": 23.2, "JMA": 20.3}, "max_so_far": 23.0, "observation": {"current_temp": 20.0, "humidity": null, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 19.583333333333332}, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "hong kong", "timestamp": "2026-04-13T18:30:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.47999999999999987, "deb_prediction": 27.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.4, "humidity": 77.0, "wind_speed_kt": 2.2, "visibility_mi": null, "local_hour": 18.666666666666668}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "hong kong", "timestamp": "2026-04-13T18:30:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10952569444444488, "deb_prediction": 27.5, "ensemble": {"p10": 28.0, "median": 28.3, "p90": 28.6}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.4, "humidity": 77.0, "wind_speed_kt": 2.2, "visibility_mi": null, "local_hour": 18.666666666666668}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "busan", "timestamp": "2026-04-14T03:00:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 19.3, "raw_sigma": 1.8499999999999996, "deb_prediction": 17.1, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 15.8, "ECMWF": 17.1, "GFS": 16.3, "ICON": 18.4, "GEM": 19.5, "JMA": 15.8}, "max_so_far": 19.0, "observation": {"current_temp": 19.0, "humidity": null, "wind_speed_kt": 8.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 19, "p": 0.321}, {"v": 20, "p": 0.303}, {"v": 21, "p": 0.215}, {"v": 22, "p": 0.115}], "shadow_prob_snapshot": [{"v": 19, "p": 0.254}, {"v": 20, "p": 0.247}, {"v": 21, "p": 0.204}, {"v": 22, "p": 0.144}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 19.3, "calibrated_sigma": 2.4974999999999996}
{"city": "busan", "timestamp": "2026-04-14T03:00:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 19.3, "raw_sigma": 0.9765625, "deb_prediction": 17.1, "ensemble": {"p10": 15.0, "median": 16.0, "p90": 17.5}, "multi_model": {"Open-Meteo": 15.8, "ECMWF": 17.1, "GFS": 16.3, "ICON": 18.4, "GEM": 19.5, "JMA": 15.8}, "max_so_far": 19.0, "observation": {"current_temp": 19.0, "humidity": null, "wind_speed_kt": 8.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 19, "p": 0.48}, {"v": 20, "p": 0.396}, {"v": 21, "p": 0.125}], "shadow_prob_snapshot": [{"v": 19, "p": 0.4}, {"v": 20, "p": 0.359}, {"v": 21, "p": 0.186}, {"v": 22, "p": 0.055}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 19.3, "calibrated_sigma": 1.318359375}
{"city": "seoul", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 23.3, "raw_sigma": 3.8000000000000007, "deb_prediction": 18.7, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 16.3, "ECMWF": 22.3, "GFS": 14.7, "ICON": 21.0, "GEM": 21.8, "JMA": 16.3}, "max_so_far": 23.0, "observation": {"current_temp": 23.0, "humidity": null, "wind_speed_kt": 5.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.184}, {"v": 24, "p": 0.181}, {"v": 25, "p": 0.167}, {"v": 26, "p": 0.143}], "shadow_prob_snapshot": [{"v": 23, "p": 0.221}, {"v": 24, "p": 0.216}, {"v": 25, "p": 0.189}, {"v": 26, "p": 0.148}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.3, "calibrated_sigma": 3.0}
{"city": "seoul", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 23.3, "raw_sigma": 0.6666666666666673, "deb_prediction": 18.7, "ensemble": {"p10": 17.7, "median": 18.2, "p90": 19.3}, "multi_model": {"Open-Meteo": 16.3, "ECMWF": 22.3, "GFS": 14.7, "ICON": 21.0, "GEM": 21.8, "JMA": 16.3}, "max_so_far": 23.0, "observation": {"current_temp": 23.0, "humidity": null, "wind_speed_kt": 5.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.569}, {"v": 24, "p": 0.391}, {"v": 25, "p": 0.04}], "shadow_prob_snapshot": [{"v": 23, "p": 0.498}, {"v": 24, "p": 0.398}, {"v": 25, "p": 0.104}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.3, "calibrated_sigma": 0.9000000000000009}
{"city": "tokyo", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 22.7, "raw_sigma": 1.3499999999999996, "deb_prediction": 22.3, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 20.6, "ECMWF": 22.9, "GFS": 22.7, "ICON": 21.7, "GEM": 23.3, "JMA": 20.6}, "max_so_far": 20.0, "observation": {"current_temp": 20.0, "humidity": null, "wind_speed_kt": 7.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.285}, {"v": 22, "p": 0.257}, {"v": 24, "p": 0.188}, {"v": 21, "p": 0.137}], "shadow_prob_snapshot": [{"v": 23, "p": 0.294}, {"v": 22, "p": 0.263}, {"v": 24, "p": 0.188}, {"v": 21, "p": 0.134}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 22.7, "calibrated_sigma": 1.3014441113240975}
{"city": "tokyo", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 23.089999999999996, "raw_sigma": 1.2890624999999998, "deb_prediction": 22.3, "ensemble": {"p10": 21.8, "median": 24.0, "p90": 25.0}, "multi_model": {"Open-Meteo": 20.6, "ECMWF": 22.9, "GFS": 22.7, "ICON": 21.7, "GEM": 23.3, "JMA": 20.6}, "max_so_far": 20.0, "observation": {"current_temp": 20.0, "humidity": null, "wind_speed_kt": 7.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.303}, {"v": 24, "p": 0.24}, {"v": 22, "p": 0.216}, {"v": 25, "p": 0.107}], "shadow_prob_snapshot": [{"v": 23, "p": 0.313}, {"v": 24, "p": 0.244}, {"v": 22, "p": 0.218}, {"v": 25, "p": 0.103}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.089999999999996, "calibrated_sigma": 1.2428312132581776}
{"city": "busan", "timestamp": "2026-04-15T05:00:00.000Z", "date": "2026-04-15", "temp_symbol": "°C", "raw_mu": 22.3, "raw_sigma": 0.5699999999999995, "deb_prediction": 21.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 21.9}, "max_so_far": 22.0, "observation": {"current_temp": 21.0, "humidity": null, "wind_speed_kt": 11.0, "visibility_mi": null, "local_hour": 14.866666666666667}, "peak_status": "past", "prob_snapshot": [{"v": 22, "p": 0.606}, {"v": 23, "p": 0.375}, {"v": 24, "p": 0.019}], "shadow_prob_snapshot": [{"v": 22, "p": 0.602}, {"v": 23, "p": 0.377}, {"v": 24, "p": 0.021}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 22.3, "calibrated_sigma": 0.5797635451114579}
{"city": "busan", "timestamp": "2026-04-15T05:00:00.000Z", "date": "2026-04-15", "temp_symbol": "°C", "raw_mu": 23.4, "raw_sigma": 0.5699999999999995, "deb_prediction": 23.0, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 21.9, "ECMWF": 24.7, "GFS": 23.4, "ICON": 21.2, "GEM": 24.9, "JMA": 21.9}, "max_so_far": 22.0, "observation": {"current_temp": 21.0, "humidity": null, "wind_speed_kt": 11.0, "visibility_mi": null, "local_hour": 14.866666666666667}, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.513}, {"v": 24, "p": 0.404}, {"v": 22, "p": 0.057}, {"v": 25, "p": 0.027}], "shadow_prob_snapshot": [{"v": 23, "p": 0.518}, {"v": 24, "p": 0.405}, {"v": 22, "p": 0.053}, {"v": 25, "p": 0.024}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.4, "calibrated_sigma": 0.5587884763366653}
{"city": "busan", "timestamp": "2026-04-15T05:00:00.000Z", "date": "2026-04-15", "temp_symbol": "°C", "raw_mu": 23.939999999999998, "raw_sigma": 0.6621236111111106, "deb_prediction": 23.1, "ensemble": {"p10": 23.5, "median": 23.8, "p90": 24.3}, "multi_model": {"Open-Meteo": 21.9, "ECMWF": 24.7, "GFS": 24.0, "ICON": 21.2, "GEM": 24.9, "JMA": 21.9}, "max_so_far": 22.0, "observation": {"current_temp": 21.0, "humidity": null, "wind_speed_kt": 11.0, "visibility_mi": null, "local_hour": 14.866666666666667}, "peak_status": "past", "prob_snapshot": [{"v": 24, "p": 0.553}, {"v": 23, "p": 0.241}, {"v": 25, "p": 0.191}, {"v": 22, "p": 0.015}], "shadow_prob_snapshot": [{"v": 24, "p": 0.565}, {"v": 23, "p": 0.236}, {"v": 25, "p": 0.186}, {"v": 22, "p": 0.013}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.939999999999998, "calibrated_sigma": 0.6441124755606287}
+85 -6
View File
@@ -1,10 +1,14 @@
x-polyweather-base: &polyweather-base
build: .
image: polyweather-app:latest
env_file:
- .env
services:
polyweather:
build: .
<<: *polyweather-base
container_name: polyweather_bot
restart: unless-stopped
env_file:
- .env
volumes:
# Persist runtime data outside git workspace.
# Host path defaults to /var/lib/polyweather and can be overridden in .env.
@@ -17,12 +21,10 @@ services:
user: "${UID:-1000}:${GID:-1000}"
polyweather_web:
build: .
<<: *polyweather-base
container_name: polyweather_web
restart: unless-stopped
command: python web/app.py
env_file:
- .env
volumes:
# Web service shares the same runtime data directory as bot/state tasks.
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
@@ -31,3 +33,80 @@ services:
- "8000:8000"
# UID/GID are mainly useful on Linux hosts to avoid root-owned output files.
user: "${UID:-1000}:${GID:-1000}"
polyweather_prewarm:
<<: *polyweather-base
container_name: polyweather_prewarm
restart: unless-stopped
profiles: ["workers"]
command: python scripts/prewarm_dashboard_worker.py --include-detail --include-market
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
user: "${UID:-1000}:${GID:-1000}"
polyweather_prometheus:
image: prom/prometheus:v3.4.1
container_name: polyweather_prometheus
restart: unless-stopped
profiles: ["monitoring"]
depends_on:
- polyweather_web
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=15d"
- "--web.enable-lifecycle"
volumes:
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/prometheus:/prometheus
ports:
- "${POLYWEATHER_PROMETHEUS_PORT:-9090}:9090"
polyweather_alertmanager:
image: prom/alertmanager:v0.28.1
container_name: polyweather_alertmanager
restart: unless-stopped
profiles: ["monitoring"]
depends_on:
- polyweather_alert_relay
command:
- "--config.file=/etc/alertmanager/alertmanager.yml"
- "--storage.path=/alertmanager"
volumes:
- ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/alertmanager:/alertmanager
ports:
- "${POLYWEATHER_ALERTMANAGER_PORT:-9093}:9093"
polyweather_alert_relay:
<<: *polyweather-base
container_name: polyweather_alert_relay
restart: unless-stopped
profiles: ["monitoring"]
command: python scripts/alertmanager_telegram_relay.py
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
ports:
- "${POLYWEATHER_ALERT_RELAY_PORT:-9099}:9099"
user: "${UID:-1000}:${GID:-1000}"
polyweather_grafana:
image: grafana/grafana-oss:12.0.2
container_name: polyweather_grafana
restart: unless-stopped
profiles: ["monitoring"]
depends_on:
- polyweather_prometheus
environment:
GF_SECURITY_ADMIN_USER: ${POLYWEATHER_GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${POLYWEATHER_GRAFANA_ADMIN_PASSWORD:-polyweather}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/grafana:/var/lib/grafana
ports:
- "${POLYWEATHER_GRAFANA_PORT:-3001}:3000"
+66 -4
View File
@@ -1,6 +1,6 @@
# PolyWeather API 文档(v1.5.1
最后更新:`2026-03-21`
最后更新:`2026-03-24`
本文档描述当前对外可用 API 口径(`web/app.py` + `web/routes.py` + `frontend/app/api/*`)。
@@ -44,9 +44,70 @@ flowchart LR
- `market_scan.available`
- `market_scan.signal_label`
- `market_scan.edge_percent`
- `market_scan.anchor_model / anchor_high / anchor_settlement`
- `market_scan.yes_buy / no_buy`
- `market_scan.primary_market.tradable`
- `peak.first_h / peak.last_h / peak.status`
- `vertical_profile_signal.heating_setup / suppression_risk / trigger_risk / mixing_strength`
- `taf.signal.peak_window / suppression_level / disruption_level / markers`
### `detail` 新增结构信号说明
`/api/city/{name}/detail` 现在会返回一组更偏交易场景的结构字段:
#### 1. `peak`
- `first_h`:预计峰值窗口起始小时
- `last_h`:预计峰值窗口结束小时
- `status``before_peak | near_peak | after_peak`
这组字段用于让日内结构信号围绕真实峰值窗口分析,而不是固定只看下午。
#### 2. `vertical_profile_signal`
重点字段:
- `source`
- `window`
- `cape_max`
- `cin_min`
- `lifted_index_min`
- `boundary_layer_height_max`
- `shear_10m_180m_max`
- `suppression_risk`
- `trigger_risk`
- `mixing_strength`
- `shear_risk`
- `heating_setup`
- `heating_score`
- `summary_zh`
- `summary_en`
这组字段对应前端“高空结构信号 / Upper-Air Structure”卡片。
#### 3. `taf.signal`
仅对**非香港机场城市**启用。当前已支持解析:
- `FM`
- `TEMPO`
- `BECMG`
- `PROB30`
- `PROB40`
重点字段:
- `peak_window`
- `segments`
- `markers`
- `suppression_level`
- `disruption_level`
- `wind_shift`
- `summary_zh`
- `summary_en`
`markers` 会被前端温度走势图拿来做 `TAF 时段 / TAF Timing` 标记。
## 4. 鉴权与账户接口
@@ -134,6 +195,7 @@ flowchart LR
- `cities` / `summary` / `history`BFF 支持 `ETag + 304`
- `summary?force_refresh=true``Cache-Control: no-store`
- 详情接口与支付接口:`no-store`
- `METAR` / `TAF` / settlement current 由后端各自维护短 TTL 缓存
## 9. 调试示例
@@ -173,8 +235,8 @@ curl -s http://127.0.0.1:8000/api/system/status | python3 -m json.tool
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
```
## 10. 开口径说明
## 10. AGPL 与公开口径说明
对外公开文档仅覆盖通用 API 契约生产商业策略参数不在公开文档披露。
本仓库代码自 `2026-03-30` 起采用 `AGPL-3.0-only`对外公开文档仅覆盖通用 API 契约生产商业策略参数、私有运营阈值与托管服务能力不在公开文档披露。
详见:[Open-Core 与商用边界](OPEN_CORE_POLICY.md)
详见:[AGPL-3.0 与商用边界](OPEN_CORE_POLICY.md)
+5 -5
View File
@@ -41,14 +41,14 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
## 5. 建议的开源边界
## 5. 许可证与商用边界
请按 Open-Core 执行
当前仓库代码采用 `AGPL-3.0-only`
- 开:基础能力与通用支付流程。
- 私有:商业风控、营销策略、关键运营参数、内部审计策略。
- 开:基础能力与通用支付流程源码
- 不随代码许可证授权:商业风控、营销策略、关键运营参数、内部审计策略、品牌与托管服务资产
详见:[Open-Core 与商用边界](OPEN_CORE_POLICY.md)
详见:[AGPL-3.0 与商用边界](OPEN_CORE_POLICY.md)
## 6. 上线检查清单(收费前)
+135 -5
View File
@@ -83,6 +83,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
前端:
- `POLYWEATHER_API_BASE_URL`
- `POLYWEATHER_OPS_ADMIN_EMAILS`(如果启用 `/ops` 页面级管理员守卫)
如果启用登录:
@@ -106,7 +107,11 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `POLYWEATHER_PAYMENT_ENABLED`
- `POLYMARKET_MARKET_SCAN_ENABLED`
- `POLYGON_WALLET_WATCH_ENABLED`
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`
- `TELEGRAM_ALERT_PUSH_ENABLED`
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`(已退役,建议保持 `false`
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED`
- `POLYWEATHER_GROQ_COMMENTARY_ENABLED`
### 4.3 L3:运行调优项
@@ -118,13 +123,37 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- 各类 `*_TIMEOUT_SEC`
- 各类 `*_COOLDOWN_SEC`
- 各类 `*_INTERVAL_SEC`
- `TELEGRAM_ALERT_MIN_TRIGGER_COUNT`
- `TELEGRAM_ALERT_MIN_SEVERITY`
- `TELEGRAM_ALERT_MISPRICING_ONLY`
- `TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC`
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC`
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
- `POLYWEATHER_PAYMENT_RPC_URLS`
- `TAF_CACHE_TTL_SEC`
- `POLYWEATHER_PREWARM_INTERVAL_SEC`
- `POLYWEATHER_PREWARM_JITTER_SEC`
- `POLYWEATHER_PREWARM_CITIES`
- `POLYWEATHER_PREWARM_INCLUDE_DETAIL`
- `POLYWEATHER_PREWARM_INCLUDE_MARKET`
- `POLYWEATHER_PREWARM_FORCE_REFRESH`
- `POLYWEATHER_GROQ_COMMENTARY_MODEL`
- `POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC`
- `POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC`
策略:
- 先用默认值
- 出现性能或运维问题时再调
当前默认预热名单优先覆盖:
- 亚洲:Shanghai、Beijing、Shenzhen、Wuhan、Chengdu、Chongqing、Hong Kong、Taipei、Singapore、Tokyo、Seoul、Busan
- 中东:Ankara、Istanbul
- 欧洲:London、Paris、Madrid
默认不再包含美国城市;如果线上 `.env` 已手动设置 `POLYWEATHER_PREWARM_CITIES`,则会以你的显式配置为准。
### 4.4 L4:敏感项
这些变量不应写进公开文档截图,也不应提交到仓库。
@@ -138,6 +167,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `METEOBLUE_API_KEY`
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
- `POLYMARKET_SECRET_KEY`
- `GROQ_API_KEY`
## 5. 推荐部署矩阵
@@ -158,15 +188,21 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `POLYWEATHER_AUTH_ENABLED`
- `POLYWEATHER_AUTH_REQUIRED`
- `POLYWEATHER_OPS_ADMIN_EMAILS`
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
- `NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL`
- `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS`
- `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS`
- `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES`
说明:
- `/ops` 页面本身部署在 Vercel 前端下,但管理员写接口权限仍由后端 `POLYWEATHER_OPS_ADMIN_EMAILS` 控制。
- 因此,Vercel 侧不需要配置管理员邮箱白名单;管理员判定发生在 VPS / FastAPI 后端。
- `/ops` 现在是前后端双层限制:
- 前端页面入口读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
- 后端写接口同样读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
- 因此,Vercel 和 VPS / Docker 两侧都应配置相同的管理员邮箱白名单。
不要把后端专用密钥全搬进 Vercel。
@@ -190,6 +226,9 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=true
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false
NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
```
### 6.2 后端最小变量
@@ -199,16 +238,38 @@ TELEGRAM_BOT_TOKEN=...
TELEGRAM_CHAT_ID=...
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
POLYWEATHER_STATE_STORAGE_MODE=dual
POLYWEATHER_STATE_STORAGE_MODE=sqlite
UID=1000
GID=1000
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=false
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
TAF_CACHE_TTL_SEC=900
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=...
TELEGRAM_ALERT_PUSH_ENABLED=true
TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
TELEGRAM_ALERT_MIN_SEVERITY=medium
TELEGRAM_ALERT_MISPRICING_ONLY=true
TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true
POLYWEATHER_PREWARM_INTERVAL_SEC=300
POLYWEATHER_PREWARM_JITTER_SEC=20
POLYWEATHER_PREWARM_INCLUDE_DETAIL=true
POLYWEATHER_PREWARM_INCLUDE_MARKET=true
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
POLYWEATHER_GROQ_COMMENTARY_ENABLED=false
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
```
说明:
@@ -217,8 +278,77 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=...
- Windows / macOS 一般可以直接保留默认值。
- `POLYWEATHER_RUNTIME_DATA_DIR` 建议放在仓库外,例如 `/var/lib/polyweather`
- `docker-compose.yml` 会把这个目录同时挂载到容器内的 `/var/lib/polyweather``/app/data`,兼容现有缓存与 SQLite 路径。
- `POLYWEATHER_STATE_STORAGE_MODE` 当前推荐先用 `dual`,验证后再切 `sqlite`
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`
- 机器人市场监控当前以 `关注清单` 为主,按固定间隔主动推送。
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
- `POLYMARKET_WALLET_ACTIVITY_ENABLED` 已退役,保留为 `false` 即可,不建议再启用钱包异动监听。
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true` 时,建议同时启用独立 worker 或 bot 内嵌预热线程。
- `POLYWEATHER_BACKEND_URL` 仅在独立 `polyweather_prewarm` worker 容器中使用,建议设为 `http://polyweather_web:8000`,不要写 `127.0.0.1`
- `POLYWEATHER_GROQ_COMMENTARY_ENABLED=false` 表示默认仍走规则文案;只有在确实配置了 `GROQ_API_KEY` 时才建议开启。
### 6.3 Dashboard 预热 worker 推荐变量
```env
POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true
POLYWEATHER_PREWARM_INTERVAL_SEC=300
POLYWEATHER_PREWARM_JITTER_SEC=20
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
POLYWEATHER_PREWARM_INCLUDE_DETAIL=true
POLYWEATHER_PREWARM_INCLUDE_MARKET=true
POLYWEATHER_PREWARM_FORCE_REFRESH=false
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
```
说明:
- 这组变量用于后台定向预热热点城市,避免用户点击城市时才冷启动拉 detail。
- 如果使用独立 `polyweather_prewarm` 容器,`POLYWEATHER_BACKEND_URL` 必须指向容器网络中的 `polyweather_web`
### 6.4 Groq 解读增强层
```env
POLYWEATHER_GROQ_COMMENTARY_ENABLED=true
GROQ_API_KEY=...
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
```
说明:
- 这层只负责把结构化信号改写成短摘要,不替代真实模型、机场锚点和结算逻辑。
- Groq 调用失败时,系统会自动回退到规则文案。
### 6.5 机器人市场监控建议配置
这套配置用于替代旧的钱包异动监听,围绕市场本身做两类推送:
- `关键提醒`:实时错价/触发条件满足时发送
- `关注清单`:按亚洲时区定时推送当日重点市场摘要
推荐值:
```env
TELEGRAM_ALERT_PUSH_ENABLED=true
TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
TELEGRAM_ALERT_MIN_SEVERITY=medium
TELEGRAM_ALERT_MISPRICING_ONLY=true
TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
```
说明:
- `TELEGRAM_ALERT_MISPRICING_ONLY=true` 表示关键提醒优先围绕错价/市场触发,不把机器人做成泛通知器。
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800` 表示频道每 30 分钟主动推送一轮机会清单。
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5` 建议先保持较小,避免机器人一次推太多城市。
- `POLYMARKET_WALLET_ACTIVITY_ENABLED=false` 表示停用旧的钱包异动监听,统一收敛到市场监控。
## 7. 当前建议的运维规则
+653
View File
@@ -0,0 +1,653 @@
# EMOS + LGBM 系统说明(中文)
本文档用于完整说明 PolyWeather 当前的两条统计/机器学习链路:
- `EMOS`:概率后处理与校准链路
- `LGBM`:日最高温点预测辅助模型
重点不只是“模型怎么训练”,还包括:
- 这些模型依赖什么历史数据
- 真值和训练特征现在如何长期保存
- 为什么过去样本一直不够
- 当前线上到底运行在哪个模式
- 现在能做什么,不能做什么
本文档基于仓库当前实现与最近一轮重建结果,适合作为:
- 项目内部模型说明
- 运维与数据治理说明
- 未来继续扩展 EMOS/LGBM 的基线文档
---
## 1. 总览
PolyWeather 当前不是“用一个模型替代所有东西”,而是多层结构:
1. 多源天气采集层
2. `DEB` 业务主预测层
3. `LGBM` 轻量点预测辅助层
4. `EMOS` 概率校准层
5. 市场概率/桶命中评估层
可以简化理解为:
```text
天气源 / 观测 / 历史真值
DEB 主预测
LGBM 辅助点预测
EMOS 对概率分布做后处理
市场概率 / shadow / rollout 门禁
```
其中:
- `DEB` 仍然是当前业务主路径
- `LGBM` 是辅助预测源,不是主路径
- `EMOS` 是概率后处理,不是基础天气模型
---
## 2. 两条链路各自负责什么
### 2.1 EMOS 负责什么
`EMOS` 的全称通常指 Ensemble Model Output Statistics。
在本项目里,它的角色不是重新预测温度,而是:
- 把已有的预测结果做概率后处理
- 让输出分布更“可校准”
- 让桶概率和市场评估更稳定
EMOS 关注的是:
- `raw_mu`
- `raw_sigma`
- `deb_prediction`
- `ens_median`
- `ensemble_spread`
- `max_so_far_gap`
- `peak_flag`
- 最终真实 `actual_high`
它最终输出的是一套“经过校准的概率分布”,而不是单一温度值。
所以 EMOS 的核心衡量指标不是单纯 MAE,而更看重:
- `CRPS`
- `bucket_hit_rate`
- `bucket_brier`
### 2.2 LGBM 负责什么
`LGBM` 是一个轻量级的回归模型,用来预测:
- `actual_high`(日最高温)
它吃的是:
- 历史真值 lag 特征
- 多模型 forecast
- `deb_prediction`
- 当前观测特征
- 时间特征
它输出的是:
- 一个点预测 `actual_high`
然后这个点预测可以作为:
- 额外 forecast 源
- 供 DEB / 运营 / 研究参考
所以它和 EMOS 的区别非常重要:
- `LGBM`:做点预测
- `EMOS`:做概率校准
---
## 3. 当前代码结构
### 3.1 EMOS 相关
核心文件:
- [probability_calibration.py](/E:/web/PolyWeather/src/analysis/probability_calibration.py)
- [probability_rollout.py](/E:/web/PolyWeather/src/analysis/probability_rollout.py)
- [fit_probability_calibration.py](/E:/web/PolyWeather/scripts/fit_probability_calibration.py)
- [evaluate_probability_calibration.py](/E:/web/PolyWeather/scripts/evaluate_probability_calibration.py)
- [build_probability_shadow_report.py](/E:/web/PolyWeather/scripts/build_probability_shadow_report.py)
- [judge_probability_rollout.py](/E:/web/PolyWeather/scripts/judge_probability_rollout.py)
核心产物:
- [default.json](/E:/web/PolyWeather/artifacts/probability_calibration/default.json)
- [evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
- [shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
- [rollout_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/rollout_report.json)
- [training_samples.json](/E:/web/PolyWeather/artifacts/probability_calibration/training_samples.json)
### 3.2 LGBM 相关
核心文件:
- [lgbm_daily_high.py](/E:/web/PolyWeather/src/models/lgbm_daily_high.py)
- [lgbm_features.py](/E:/web/PolyWeather/src/models/lgbm_features.py)
- [train_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/train_lgbm_daily_high.py)
- [report_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/report_lgbm_daily_high.py)
核心产物:
- [lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
- [lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
---
## 4. 为什么之前样本总是上不去
这件事是理解当前状态的关键。
过去项目里有一个结构性问题:
- `daily_records_store` 同时承担了
- 运行态缓存
- 历史训练数据来源
但运行态层会把 `daily_records` 硬裁成最近 14 天。
这意味着:
- 对线上运行来说没问题
- 对训练来说,历史监督样本会不断被删掉
结果就是:
- 城市越来越多
- 训练历史反而越来越稀
- `LGBM` 很容易只有二十几条样本
- `EMOS` 也只能靠有限 snapshot/daily_record 拼起来
这不是“模型太差”,而是“数据主存设计不对”。
---
## 5. 这次历史真值治理做了什么
现在已经把“运行态缓存”和“长期训练主存”拆开了。
### 5.1 `daily_records_store`
继续保留,但只作为:
- 最近 14 天运行态缓存
它不再承担长期训练历史职责。
### 5.2 `truth_records_store`
新增永久真值表,作为长期训练真值主存。
当前核心字段包括:
- `city`
- `target_date`
- `actual_high`
- `settlement_source`
- `settlement_station_code`
- `settlement_station_label`
- `truth_version`
- `updated_by`
- `updated_at`
- `source_payload_json`
- `is_final`
这张表的意义是:
- 长期保存监督真值
- 不再被 14 天缓存裁剪
- 真值来源变得可追溯
### 5.3 `truth_revisions_store`
新增真值修订审计表。
它记录:
- 老值是什么
- 新值是什么
- 来源怎么变了
- 谁改的
- 为什么改
- 什么时候改
所以现在回填不会再是“静默覆盖”。
### 5.4 `training_feature_records_store`
新增长期训练特征表。
它长期留存:
- forecasts
- deb_prediction
- mu
- probability_features
- prob_snapshot
- shadow_prob_snapshot
- calibration 摘要
它的作用是:
- 从现在开始,不再继续丢失历史训练特征
- 让未来 EMOS/LGBM 样本自然累积
---
## 6. 训练数据现在怎么来
### 6.1 EMOS 训练样本
EMOS 训练不只是需要真值,还要有“当时那一刻的预测快照”。
所以一条 EMOS 样本,本质上需要两部分:
1. 历史预测特征
2. 对应日期最终真值
当前导出的 EMOS 样本里,核心字段包括:
- `city`
- `date`
- `actual_high`
- `raw_mu`
- `raw_sigma`
- `deb_prediction`
- `ens_median`
- `ensemble_spread`
- `max_so_far_gap`
- `peak_flag`
- `sample_source`
- `settlement_source`
- `settlement_station_code`
- `truth_version`
- `truth_updated_by`
- `truth_updated_at`
也就是说,EMOS 训练样本现在已经带了真值 provenance。
### 6.2 LGBM 训练样本
LGBM 训练样本会优先从:
1. 永久真值表取监督目标
2. 长期训练特征表取历史特征
3. 再回退到必要的运行态/快照补充
当前 LGBM 样本会用到:
- 历史 `actual_high` lag
- 历史均值/趋势
- 多模型 forecast
- `deb_prediction`
- 当前观测
- 时间特征
---
## 7. Wunderground 历史回填为什么重要
这次治理里一个重点是:
- `Taipei`
- `Shenzhen`
这两个城市已经切到了市场指定的 `Wunderground` 结算口径。
之前的问题是:
- 城市注册表已经写成 `wunderground`
- 但历史回填链路还没有真正支持按指定历史日期抓 WU 历史页
所以过去它们的 `actual_high` 可能:
- 没有被正确回填
- 或者被错误来源污染
现在已经补了正式历史回填函数:
- [wunderground_sources.py](/E:/web/PolyWeather/src/data_collection/wunderground_sources.py)
它会:
1. 按 `city + target_date` 拼出对应历史页
2. 解析该日观测序列
3. 取当日最高温
4. 按市场规则做整度结算
5. 写入永久真值表
6. 记录来源与审计信息
这一步对 `Taipei/Shenzhen` 尤其关键,因为它们不是 NOAA/HKO 口径。
---
## 8. 当前线上/离线运行模式
### 8.1 概率引擎模式
当前项目仍然应该保持:
- `emos_shadow`
而不是:
- `emos_primary`
原因不是工程没接好,而是门禁还没过。
### 8.2 LGBM 角色
当前 `LGBM` 仍然只能算:
- 辅助预测源
- 研究/观测链路
不适合替代 `DEB` 主路径。
---
## 9. 当前最新状态
以下状态来自最近一轮恢复、回填和重训产物。
### 9.1 永久真值
当前永久真值表已恢复到长期历史:
- `truth_records_store`
- 最早:`2023-01-01`
- 最晚:`2026-04-02`
- 行数:约 `35138`
- 城市数:`30`
运行态缓存仍然只有近 14 天:
- `daily_records_store`
- 仍然是近两周范围
这说明:
- 长期真值主存已经从运行态缓存里分离出来了
### 9.2 真值修订
当前已有 revision 审计记录:
- `truth_revisions_store`
- 行数:`2`
这说明审计链路已经在工作。
### 9.3 Wunderground 回填
`Taipei``Shenzhen` 已按 WU 历史页完成回填。
当前这两城已经补到:
- `2026-04-02`
### 9.4 长期训练特征
当前 `training_feature_records_store` 已经接通,但历史上真正留存下来的特征仍然很少。
这意味着:
- 从现在开始不会继续丢
- 但过去没留下的那部分特征,不会凭空恢复
这也是为什么:
- 真值恢复了
- `EMOS` 样本量却没有同步大幅增长
---
## 10. 当前 EMOS 结果怎么理解
最近一轮离线评估大致是:
- `sample_count = 54`
- `delta_crps ≈ -0.0867`
- `delta_mae = 0`
- `delta_bucket_hit_rate = 0`
这说明:
- 从 `CRPS` 看,EMOS 有改善
- 但从 `MAE``top bucket hit` 看,没有明显进步
shadow 报告里更关键的问题是:
- `shadow sample_count = 48`
- `delta_bucket_brier` 仍然明显偏坏
所以 rollout 结论仍然是:
- `hold`
这不是“EMOS 无效”,而是:
- 它还没有稳定到能切主路径
### 10.1 当前阻塞点
主要阻塞仍然是:
- 样本数不够
- shadow bucket brier 退化
也就是说,当前 EMOS 状态可以总结成:
- 工程链路完整
- 数据治理大幅改善
- 发布门禁仍未通过
---
## 11. 当前 LGBM 结果怎么理解
最近一轮 LGBM 训练后,样本数已经从以前更少的状态提升到:
- `sample_count = 54`
- `train_count = 42`
- `validation_count = 12`
验证集指标大致为:
- `lgbm_mae = 1.349`
- `deb_mae = 0.875`
这说明:
- LGBM 比以前样本更充足了
- 但在验证集上仍然不如 DEB
所以当前它的定位仍然应该是:
- 辅助参考
- 不替代 DEB
---
## 12. 为什么现在 EMOS 没有像 LGBM 那样明显涨样本
这点很容易误解。
答案不是“恢复失败”,而是两条链路对数据要求不一样。
### 12.1 LGBM
LGBM 更依赖:
- 长期真值
- 基础 forecast 特征
这部分通过:
- `truth_records_store`
- `training_feature_records_store`
已经改善很多。
### 12.2 EMOS
EMOS 更依赖:
- 某一时刻的概率快照/分布特征
如果过去那些 snapshot 没有长期保存下来,那么即使今天把真值补齐了:
- 也无法凭空重建完整 EMOS 样本
所以当前现实是:
- 真值问题已经大幅改善
- 未来特征不会再继续丢
- 但过去缺失的 EMOS 快照历史仍然限制样本增长
---
## 13. 当前最重要的工程判断
### 13.1 已经完成的
这些现在可以认为已经完成:
- 真值主存从运行态缓存里拆出
- 真值 provenance 落库
- revision 审计表落地
- Wunderground 历史回填接通
- `Taipei/Shenzhen` 真值口径修正
- 长期训练特征表接通
- `/ops` 已能可视化 truth / feature / EMOS / LGBM 覆盖情况
### 13.2 还没完成的
这些仍然是后续重点:
- EMOS 样本继续自然积累
- shadow bucket brier 稳定下来
- LGBM 验证效果超过 DEB
- 让更多城市开始持续积累训练特征
---
## 14. 运维怎么看当前状态
现在最直接的入口是:
- `/ops`
这页已经能看到:
- 历史真值主表统计
- 真值来源分布
- 真值修订数量
- 长期训练特征统计
- `Taipei/Shenzhen` 的 WU 回填状态
- 城市覆盖缺口
- 模型城市覆盖
- 城市覆盖矩阵
因此,运维现在可以快速回答:
- 哪些城市真值已经长期化
- 哪些城市还没有特征积累
- 哪些城市已经能支撑 EMOS/LGBM
- 哪些城市目前仍然只能主要依赖 DEB
---
## 15. 推荐工作流
### 15.1 日常
1. 查看 `/ops`
2. 看 `truth / feature / EMOS / LGBM` 覆盖有没有继续增长
3. 看 `Taipei/Shenzhen` 的 WU 行数是否继续更新
4. 看 rollout 仍然是 `hold` 还是有改善
### 15.2 周期性重训
建议周期性执行:
```bash
./venv/Scripts/python.exe scripts/export_probability_training_dataset.py
./venv/Scripts/python.exe scripts/fit_probability_calibration.py
./venv/Scripts/python.exe scripts/evaluate_probability_calibration.py
./venv/Scripts/python.exe scripts/build_probability_shadow_report.py
./venv/Scripts/python.exe scripts/judge_probability_rollout.py
./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
```
### 15.3 真值恢复/补数
当有新的历史真值补数或回填需要时:
```bash
./venv/Scripts/python.exe scripts/restore_training_truth_history.py
./venv/Scripts/python.exe scripts/restore_training_feature_history.py
./venv/Scripts/python.exe scripts/backfill_recent_daily_actuals_from_metar.py --cities taipei shenzhen --lookback-days 14
```
说明:
- 脚本名里虽然还保留 `from_metar`
- 但当前实现已经会按 `settlement_source` 自动分发
- `wunderground` 会走 WU 历史回填分支
---
## 16. 当前最务实的结论
如果只用一句话概括当前状态:
**EMOS 和 LGBM 的工程基础已经补齐,但数据积累还在恢复期;当前最正确的策略仍然是继续以 `DEB` 为主路径,让长期真值和训练特征继续沉淀,再观察 EMOS/LGBM 是否自然变强。**
更具体一点:
- `EMOS`
- 已接好
- 可训练
- 可评估
- 可 shadow
- 但暂时不能切主路径
- `LGBM`
- 已接好
- 样本比以前更多
- 但验证集还不如 DEB
- 目前只能做辅助参考
- 数据层
- 这次治理的真正价值,是防止未来继续丢历史
- 这对两条模型链路都比继续“微调参数”更关键
---
## 17. 相关文档
若需要看更细分的历史说明,可继续参考:
- [EMOS_TRAINING_REPORT_ZH.md](/E:/web/PolyWeather/docs/EMOS_TRAINING_REPORT_ZH.md)
- [LGBM_DAILY_HIGH_ZH.md](/E:/web/PolyWeather/docs/LGBM_DAILY_HIGH_ZH.md)
- [PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md](/E:/web/PolyWeather/docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md)
- [deep-research-report.md](/E:/web/PolyWeather/docs/deep-research-report.md)
+58 -2
View File
@@ -23,7 +23,7 @@
- Framework Preset: `Next.js`
- Root Directory: `frontend`
- Build Command: `npm run build`
- Install Command: `npm install`
- Install Command: `npm ci`
如果仓库已经连接过 Vercel,通常只需要确认 `Root Directory` 仍然是 `frontend`
@@ -78,7 +78,18 @@ NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
如果不启用钱包支付,可以留空。
### 4. Telegram 入口
### 4. `/ops` 管理员页面守卫
```env
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
```
说明:
- `/ops` 现在不是只有后端接口限制,前端页面入口也会读取管理员邮箱白名单。
- 因此前端部署到 Vercel 时,也应配置 `POLYWEATHER_OPS_ADMIN_EMAILS`
### 5. Telegram 入口
```env
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
@@ -87,6 +98,20 @@ NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
只影响按钮跳转,不影响核心页面加载。
### 6. 前端观测与预热开关(推荐默认关闭)
```env
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false
NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
```
说明:
- `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false`:关闭前端自建埋点。
- `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false`:关闭前端 Web Vitals 上报。
- `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false`:关闭首页全量城市 summary 预热,避免白白消耗 Vercel function / edge 成本。
## 五、支付配置与旧部署治理
支付区现在有一层额外防护:
@@ -167,6 +192,7 @@ Vercel 部署前至少确认:
3. GitHub Actions 中 `frontend-quality` 已通过
4. 如果启用鉴权,Supabase redirect URL 已包含前端域名
5. `GET /api/payments/config` 返回的是当前最新地址,而不是旧收款合约
6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在 Vercel 与后端同时配置
## 九、常见问题
@@ -197,3 +223,33 @@ NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
```
这是钱包连接的必需项。
## 十、Vercel 成本与节流建议
### 1. 建议先关闭的项目级能力
- `Web Analytics`
- `Speed Insights`
它们对排查前端体验有价值,但在 Hobby / 低预算阶段会额外消耗数据点和边缘资源。
### 2. 建议加的 Firewall 自定义规则
如果你的 Next.js 项目根本不提供 WordPress / PHP 路径,建议在 Vercel Firewall 里先 `Log``Deny` 这条规则:
```regex
(^/(wp-admin|wp-includes|wp-content|wp-login|wordpress|xmlrpc\.php))|\.php($|\?)
```
目的:
- 在边缘层提前拦截 WordPress / PHP 扫描流量
- 避免无效请求继续触发 middleware 与 route handler
### 3. 建议的上线前检查
除了功能本身,额外确认:
1. `Web Analytics``Speed Insights` 是否真的关闭
2. `NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS` / `NEXT_PUBLIC_POLYWEATHER_WEB_VITALS` / `NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES` 是否保持关闭
3. Firewall 自定义规则是否已从 `Log` 切到 `Deny`
+310
View File
@@ -0,0 +1,310 @@
# LightGBM 日最高温模型(中文)
## 1. 目标
这套 `LightGBM` 模型是给 PolyWeather 增加一个轻量级的统计学习预测源。
它的定位不是替代:
- `DEB`
- `EMOS`
- `ECMWF / GFS / GEM / JMA / ICON / Open-Meteo / MGM / NWS`
而是作为一个新的点预测源:
`现有模型 + 观测特征 -> LGBM -> 并入 current_forecasts -> DEB -> EMOS`
第一版只做:
- `D0` 当日最高温预测
不做:
- `D1-D3`
- 小时级曲线
- 概率分布
- 独立结算源
## 2. 适用场景
这条链路是为低资源 VPS 准备的。
当前项目线上环境只有 `2GB RAM` 时,不适合引入 `TimesFM` 这类大模型,但适合用 `LightGBM` 做轻量推理。
当前方案是:
1. 训练离线完成
2. 训练产物直接提交到仓库
3. VPS 线上只加载模型文件并推理
4. VPS 不训练,不起额外服务
## 3. 文件结构
核心文件如下:
- 运行时推理:
- [src/models/lgbm_daily_high.py](/E:/web/PolyWeather/src/models/lgbm_daily_high.py)
- 特征构建:
- [src/models/lgbm_features.py](/E:/web/PolyWeather/src/models/lgbm_features.py)
- 训练脚本:
- [scripts/train_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/train_lgbm_daily_high.py)
- 训练报告脚本:
- [scripts/report_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/report_lgbm_daily_high.py)
- 模型文件:
- [artifacts/models/lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
- 模型 schema / 指标:
- [artifacts/models/lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
接入链路位置:
- Web API 聚合:
- [web/analysis_service.py](/E:/web/PolyWeather/web/analysis_service.py)
- 共享趋势引擎:
- [src/analysis/trend_engine.py](/E:/web/PolyWeather/src/analysis/trend_engine.py)
## 4. 特征说明
第一版特征固定为以下几组。
### 4.1 历史日高温特征
- `actual_high_lag_1`
- `actual_high_lag_2`
- `actual_high_lag_3`
- `actual_high_lag_7`
- `actual_high_mean_7`
- `actual_high_mean_14`
- `actual_high_trend_3`
### 4.2 当天模型特征
- `Open-Meteo`
- `ECMWF`
- `GFS`
- `GEM`
- `JMA`
- `ICON`
- `MGM`
- `NWS`
- `deb_prediction`
- `model_median`
- `model_spread`
### 4.3 当前观测特征
- `current_temp`
- `max_so_far`
- `humidity`
- `wind_speed_kt`
- `visibility_mi`
### 4.4 时间与状态特征
- `local_hour`
- `month`
- `weekday`
- `peak_status_code`
其中:
- `before = 0`
- `in_window = 1`
- `past = 2`
## 5. 训练数据来源
训练数据主要来自两份运行时历史文件:
- [data/daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
- [data/probability_training_snapshots.jsonl](/E:/web/PolyWeather/data/probability_training_snapshots.jsonl)
作用分工:
- `daily_records.json`
- 提供 `actual_high`
- 提供当天各模型 forecast
- 提供历史 `deb_prediction`
- `probability_training_snapshots.jsonl`
- 提供 `max_so_far`
- 提供 `peak_status`
- 提供观测特征快照
为后续重训,概率快照归档现在还会额外写入:
- `current_temp`
- `humidity`
- `wind_speed_kt`
- `visibility_mi`
- `local_hour`
对应代码:
- [src/analysis/probability_snapshot_archive.py](/E:/web/PolyWeather/src/analysis/probability_snapshot_archive.py)
## 6. 训练流程
训练脚本:
```bash
./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
```
训练流程如下:
1. 从历史文件构造监督样本
2. 目标值固定为 `actual_high`
3. 按日期做简单的时间顺序切分
4. 最后约 20% 做验证集
5. 先训练并评估验证集
6. 再用全量样本训练最终模型
7. 输出模型文件和 schema 文件
输出产物:
- [artifacts/models/lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
- [artifacts/models/lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
## 7. 如何看训练结果
查看训练报告:
```bash
./venv/Scripts/python.exe scripts/report_lgbm_daily_high.py
```
这个脚本会读取 schema,并打印:
- `Sample Count`
- `Train Count`
- `Valid Count`
- `LGBM MAE`
- `DEB MAE`
- `Best Single MAE`
- `Median MAE`
- `Winner`
当前这版训练结果是:
- `sample_count = 29`
- `validation_count = 12`
- `validation.lgbm_mae = 2.975`
- `validation.deb_mae = 2.267`
- `validation.best_single_mae = 1.167`
这说明:
- 当前 `LGBM` 链路已经可用
- 但现阶段验证集表现还没有超过 `DEB`
- 所以默认配置仍建议保持关闭
## 8. 线上运行逻辑
运行时推理逻辑不是“直接替代 DEB”,而是:
1. 先收集现有模型 forecast
2. 先算一版基线 `DEB`
3. 把这版 `DEB` 当作 `LGBM` 的一个输入特征
4. 输出 `LGBM` 点预测
5. 把 `LGBM` 注入 `current_forecasts`
6. 重新计算最终 `DEB`
这样做的原因是:
- `LGBM` 需要吃到 `deb_prediction` 特征
- 但最终 `DEB` 又要把 `LGBM` 当成一个新的输入模型
## 9. 环境变量
示例配置见:
- [.env.example](/E:/web/PolyWeather/.env.example)
相关变量:
```env
POLYWEATHER_LGBM_ENABLED=false
POLYWEATHER_LGBM_MODEL_PATH=/app/artifacts/models/lgbm_daily_high.txt
POLYWEATHER_LGBM_SCHEMA_PATH=/app/artifacts/models/lgbm_daily_high_schema.json
POLYWEATHER_LGBM_MIN_HISTORY_POINTS=3
```
说明:
- `POLYWEATHER_LGBM_ENABLED`
- 是否启用运行时推理
- `POLYWEATHER_LGBM_MODEL_PATH`
- 模型文件路径
- `POLYWEATHER_LGBM_SCHEMA_PATH`
- schema 文件路径
- `POLYWEATHER_LGBM_MIN_HISTORY_POINTS`
- 某城市最低历史样本门槛
默认是 `3`,原因不是最理想,而是当前整体样本仍然偏少。
如果门槛设太高,很多城市现在根本不会触发 `LGBM`
## 10. VPS 部署建议
如果你的 VPS 只有 `2GB RAM`
- 可以跑这套 `LightGBM`
- 不要在 VPS 上训练
- 不要起额外模型服务
推荐方式:
1. 在本地或开发环境训练
2. 提交模型产物
3. VPS 拉代码
4. 开启 `POLYWEATHER_LGBM_ENABLED=true`
5. 重启主服务
不推荐:
- 在 VPS 上跑训练脚本
- 把 `LightGBM` 当成长任务服务单独部署
- 同时引入大模型推理
## 11. 当前结论
这条链路已经完成了:
- 离线训练
- 模型产物固化
- 运行时懒加载
- Web / 共享分析链路注入
- 前端模型类型兼容
但当前样本量仍偏少,所以建议运营策略是:
1. 先继续积累历史 `actual_high`
2. 继续积累概率快照观测字段
3. 定期重训
4. 只有当验证集 `MAE` 持续接近或优于 `DEB` 时,再考虑默认线上开启
## 12. 常用命令
### 训练
```bash
./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
```
### 查看训练报告
```bash
./venv/Scripts/python.exe scripts/report_lgbm_daily_high.py
```
### 本地测试
```bash
./venv/Scripts/python.exe -m pytest tests/test_lgbm_features.py tests/test_lgbm_daily_high.py
```
### 编译检查
```bash
./venv/Scripts/python.exe -m compileall src web scripts tests
```
+155
View File
@@ -0,0 +1,155 @@
# 外部监控与告警说明
最后更新:`2026-04-10`
## 1. 目标
在现有轻量可观测性基础上,把 PolyWeather 补成最小可用的外部监控链路:
- Prometheus 抓取 `/metrics`
- Alertmanager 根据规则聚合告警
- Relay 把告警推到运营频道
- Grafana 展示趋势面板
- 巡检脚本补健康检查
## 2. 组件
本仓库现在内置 4 个监控组件:
- `polyweather_prometheus`
- `polyweather_alertmanager`
- `polyweather_alert_relay`
- `polyweather_grafana`
对应配置目录:
- [monitoring/prometheus/prometheus.yml](../monitoring/prometheus/prometheus.yml)
- [monitoring/prometheus/alerts.yml](../monitoring/prometheus/alerts.yml)
- [monitoring/alertmanager/alertmanager.yml](../monitoring/alertmanager/alertmanager.yml)
- [monitoring/grafana/dashboards/polyweather-overview.json](../monitoring/grafana/dashboards/polyweather-overview.json)
## 3. 启动
```bash
docker compose --profile monitoring up -d polyweather_prometheus polyweather_alertmanager polyweather_alert_relay polyweather_grafana
```
默认端口:
- Prometheus: `9090`
- Alertmanager: `9093`
- Grafana: `3001`
- Alert relay: `9099`
## 4. 环境变量
在 [.env.example](../.env.example) 里新增了这些配置:
```env
POLYWEATHER_PROMETHEUS_PORT=9090
POLYWEATHER_ALERTMANAGER_PORT=9093
POLYWEATHER_ALERT_RELAY_PORT=9099
POLYWEATHER_GRAFANA_PORT=3001
POLYWEATHER_GRAFANA_ADMIN_USER=admin
POLYWEATHER_GRAFANA_ADMIN_PASSWORD=polyweather
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
```
说明:
- `POLYWEATHER_MONITORING_ALERT_CHAT_IDS` 为空时,relay 会自动回退到:
- `TELEGRAM_CHAT_IDS`
- `TELEGRAM_CHAT_ID`
- 告警发送仍复用现有 `TELEGRAM_BOT_TOKEN`
## 5. 当前告警规则
当前默认规则:
- `PolyWeatherWebDown`
- `PolyWeatherHttp5xxBurst`
- `PolyWeatherHighSourceErrorRate`
- `PolyWeatherOpenMeteoCooldownLoop`
- `PolyWeatherSlowHttpAverage`
规则文件:
- [monitoring/prometheus/alerts.yml](../monitoring/prometheus/alerts.yml)
## 6. 当前 Grafana 面板
预置了一个最小仪表板:
- `PolyWeather Overview`
包含这些图:
- HTTP Requests by Status
- HTTP Latency
- Source Requests by Outcome
- Source Error Rate (15m)
## 7. 巡检脚本
手动巡检:
```bash
python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
```
这个脚本会检查:
- `/healthz`
- `/api/system/status`
- `/metrics`
任何一项失败都会非零退出,适合挂到 crontab 或 systemd timer。
## 8. 当前内置运行态观测
除了 Prometheus / Grafana 这套外部监控,当前后端还内置了更贴业务的只读运行态:
- `/api/system/status`
- `/ops`
目前已覆盖:
- `prewarm` worker 是否启用、线程 / heartbeat 是否活着
- 最近一轮 prewarm 的:
- `cycle_count`
- `success_count / failure_count`
- `last_started_at / last_finished_at`
- `last_summary_ok / last_detail_ok / last_market_ok`
- 缓存桶条目数:
- `api_cache`
- `metar`
- `taf`
- `nmc`
- `settlement`
- `open_meteo forecast / ensemble / multi-model`
- `summary` 分析缓存命中率:
- `total_requests`
- `cache_hits / cache_misses`
- `hit_rate / miss_rate`
这意味着:
- 外部监控负责“服务活没活、错误有没有暴增”
- `/ops``/api/system/status` 负责“预热有没有真的跑、缓存有没有真的被打热”
## 9. 备注
这套监控现在已经具备:
- 外部抓取
- 告警规则
- Telegram 推送
- 趋势面板
- 巡检脚本
但它仍是“最小可用版”,还没有覆盖:
- 节点级 CPU / 内存 / 磁盘
- 数据库体积趋势
- 更细粒度支付指标
- 按城市/来源拆分的业务 SLA
+30 -48
View File
@@ -1,63 +1,45 @@
# Open-Core 与商用边界
# AGPL-3.0 与商用边界
最后更新:`2026-03-14`
最后更新:`2026-03-30`
## 1. 目标
## 1. 当前许可证
在保持社区可用性的前提下,保护商业化阶段的核心经营资产
- 本仓库代码自 `2026-03-30` 起采用 **GNU Affero General Public License v3.0 only**`AGPL-3.0-only`
- 该许可证适用于仓库中未另行声明许可证的源代码与文档。
- 如果你修改本项目并通过网络向用户提供服务,需按 AGPL 第 13 条向用户提供对应源码。
## 2. 仓库公开范围(可开源)
## 2. 旧版本说明
- 天气数据采集与标准化(METAR / Open-Meteo / MGM 接口层)
- DEB 与基础趋势分析、概率桶计算
- Dashboard 基础体验与 API/BFF 结构。
- Telegram Bot 基础命令与基础积分机制。
- 合约支付标准流程(钱包绑定、intent、提交、确认、补单)。
- 在本次切换前已经发布的 MIT 版本,仍按其原始许可证生效
- 本次变更不会追溯撤销既往已发布版本的 MIT 授权
## 3. 生产私有范围(建议不公开)
## 3. 仓库公开范围
- 商业风控参数与规则库:
- 错价信号阈值组合、推送阈值、异常检测策略
- 运营策略资产:
- 用户分层规则、促销规则、留存策略、活动模板。
- 付费系统敏感细节:
- 实时对账容错阈值、退款审计策略、内部财务映射规则。
- 私有运维资产:
- 生产告警路由、内部频道映射、应急脚本与排障手册。
- 天气数据采集与标准化(METAR / Open-Meteo / MGM / 官方结算源接口层)。
- DEB、基础趋势分析、概率桶、历史对账、前端看板与 Bot 基础能力
- 标准支付流程、链上收款合约与公开 API/BFF 结构。
## 4. 配置与数据安全红线
## 4. 不在仓库许可证授权范围内的资产
- 不提交:`.env`、私钥、API key、机器人 token
- 不提交:生产数据库、运行时状态文件、支付流水快照
- 不提交:用户身份信息、钱包映射、订阅原始审计日志
- 商标、品牌名、域名、Logo、商店素材与市场宣传文案
- 生产数据库、用户资料、钱包映射、订阅审计日志、内部报表
- 私有运营脚本、增长工具、内部风控参数、收费策略细节与内部阈值
- 托管服务本身、SLA、客服、运维值守与内部告警路由。
## 5. 推荐发布模式
## 5. 配置与数据安全红线
### 5.1 Community Edition(开源)
- 不提交:`.env`、私钥、API key、机器人 token、第三方 service role key。
- 不提交:生产数据库、运行态快照、支付流水快照、用户身份信息。
- 不提交:仅用于线上商业判断的私有规则库与内部操作手册。
- 提供基础分析与基础看板。
- 可选保留只读市场扫描。
- 默认关闭商业化运营规则。
## 6. 对部署者的要求
### 5.2 Production Edition(私有)
- 若你提供公开网络服务,应在产品界面中提供清晰可访问的源码入口。
- 若你修改了本项目再对外提供网络服务,应公开与你实际运行版本对应的源码。
- 若你使用了仓库外的私有数据、商标或运营资产,这些额外资产不因 AGPL 自动获得授权。
- 启用收费、订阅、积分抵扣、风控、私有监控。
- 仅在私有仓库维护运营策略与敏感参数。
## 7. 法务与运营建议
## 6. 文档口径规范
对外文档仅描述:
- 能力边界与使用方式。
- 可公开的技术架构。
- 不包含可被直接复刻的商业参数。
不对外文档描述:
- 具体策略阈值、用户分层细则、收益归因规则。
## 7. 许可证与法务建议(简版)
- 建议保持仓库代码许可证与商标/品牌授权分离。
- 若提供商业服务,建议在官网补充服务条款与隐私政策。
- 对“订阅权益”与“可用性”做明确 SLA 与免责边界。
- 代码许可证与商标/品牌授权应继续分离管理。
- 官网应补充服务条款、隐私政策与付费权益说明。
- 若后续接受外部贡献,再次调整许可证前应先确认贡献者版权归属与再许可条件。
+33 -1
View File
@@ -1,6 +1,6 @@
# Ops 运营后台说明
最后更新:`2026-03-21`
最后更新:`2026-04-10`
## 1. 入口
@@ -25,9 +25,12 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
- 系统健康
- SQLite / rollout / metrics 摘要
- 支付运行态
- prewarm worker 运行态
- 缓存桶状态与 summary cache hit/miss
- 当前会员
- 周榜
- 支付异常单
- 漏斗转化面板
### 写能力
@@ -98,7 +101,36 @@ python scripts/reconcile_subscription_by_email.py --email <user_email>
## 7. 备注
### 7.1 当前 prewarm / 缓存观测项
`/ops` 里的系统状态卡目前已额外展示:
- `prewarm` 是否启用
- `thread_alive` / `heartbeat_age_sec`
- 最近一轮:
- `cycle_count`
- `success_count / failure_count`
- `last_started_at / last_finished_at`
- `last_summary_ok / last_detail_ok / last_market_ok`
- 缓存桶数量:
- `api_cache`
- `metar`
- `taf`
- `nmc`
- `settlement`
- `open_meteo forecast / ensemble / multi-model`
- `summary` 层缓存命中率:
- `total_requests`
- `cache_hits / cache_misses`
- `hit_rate / miss_rate`
### 7.2 当前用途边界
`/ops` 是运营后台最小版,不是完整 Admin 平台。当前目标是:
- 让会员、积分、支付事故、系统状态可查
- 让常见人工操作不必再直接写 SQL
外部监控与告警栈说明见:
- [MONITORING_ZH.md](./MONITORING_ZH.md)
+286
View File
@@ -0,0 +1,286 @@
# PolyWeather TAF 信号说明(TAF_SIGNAL_ZH
本文档说明 PolyWeather 当前如何把 `TAF`(机场终端预报)接入“今日日内分析”,以及这些信号在交易判断里到底代表什么。
本文档只描述**当前实现**,不夸大、不脑补。
---
## 一、TAF 在项目中的定位
在 PolyWeather 里,`TAF` 不是主温度模型,也不是结算源。
它的定位是:
1. **机场侧确认层**
- 用来补充说明机场在峰值窗口附近会不会出现云、雨、雷暴或风向切换。
2. **压温 / 扰动风险提示层**
- 用来判断“机场最高温是否可能因为天气扰动被压低”。
3. **走势图时间轴联动层**
- 用来在温度走势图上标出 `FM / TEMPO / BECMG / PROB30/40` 对应的时段。
它**不负责**
1. 直接提供多模型最高温数值
2. 替代 `DEB`
3. 替代机场实况 `METAR`
4. 替代官方结算源
---
## 二、后端当前怎么解析 TAF
后端入口在:
- [web/analysis_service.py](/E:/web/PolyWeather/web/analysis_service.py)
核心函数:
- `_build_taf_signal(...)`
当前会解析这些时间片:
1. `BASE`
2. `FM`
3. `TEMPO`
4. `BECMG`
5. `PROB30`
6. `PROB40`
7. `PROB30 TEMPO`
8. `PROB40 TEMPO`
并且只聚焦于:
- **峰值窗口前后**
当前窗口定义是:
- `peak.first_h - 2h`
- 到
- `peak.last_h + 1h`
也就是说,TAF 不是整段报文全量平铺,而是会优先关注**和今天高温兑现最相关的时段**。
---
## 三、当前后端真正产出的核心字段
### 1. `suppression_level`
表示机场端的**压温风险等级**。
当前逻辑来自:
1. 降水 / 雷暴关键词
- `TSRA`
- `TS`
- `VCTS`
- `SHRA`
- `SHSN`
- `SHGS`
- `RA`
- `DZ`
- `SN`
2. 低云底
- 只对 `BKN / OVC` 生效
- 如果最低云底 `<= 4000 ft`
- 会把原本 `low` 的压温风险至少抬到 `medium`
注意:
- 不是所有 `FEW / SCT / BKN / OVC` 都会直接把风险打到 `high`
- 当前实现里,**低云主要是把风险从 `low` 抬到 `medium`**
- 真正更容易触发 `high` 的,还是阵雨 / 雷暴类关键词
### 2. `disruption_level`
表示峰值窗口附近的**扰动程度**。
当前逻辑:
1. 这些时间片会至少把扰动抬到 `medium`
- `TEMPO`
- `BECMG`
- `PROB30`
- `PROB40`
- `PROB30 TEMPO`
- `PROB40 TEMPO`
2. 这些情况会把扰动抬到 `high`
- `PROB30 TEMPO`
- `PROB40 TEMPO`
- 或者该时段本身就出现强降水/雷暴类关键词
注意:
- **`TEMPO` 本身不等于 `high`**
- **`PROB40` 也不等于“确定发生”**
---
## 四、前端怎么展示
前端主要在:
- [frontend/components/dashboard/FutureForecastModal.tsx](/E:/web/PolyWeather/frontend/components/dashboard/FutureForecastModal.tsx)
- [frontend/lib/dashboard-utils.ts](/E:/web/PolyWeather/frontend/lib/dashboard-utils.ts)
当前会通过三种方式展示 TAF
### 1. 图表时间轴标记
在日内温度走势图上,当前会显示:
- `TAF 时段 / TAF Timing`
tooltip 会显示该时段摘要,例如:
- `基础时段 13:00-19:00 以稳定为主`
- `明确切换 15:00-21:00 以稳定为主`
- `临时波动 14:00-17:00 有云雨扰动`
### 2. 今日日内结构信号里的 `机场预报`
会显示类似:
- `防压温`
- `看云雨`
- `暂稳`
### 3. 顶部摘要与交易动作
系统会把:
1. 近地面结构
2. 高空结构
3. `TAF`
4. `market_signal / edge_percent / bucket crowding`
合并成更贴近交易的提示,例如:
- `偏暖侧`
- `偏谨慎`
- `先观察`
---
## 五、TAF 关键词当前在项目里的真实含义
| TAF 关键词 | 当前展示词 | 当前项目含义 |
| :-- | :-- | :-- |
| `BASE` | 基础时段 | 在第一个显式变化组出现前的默认背景天气段 |
| `FM` | 明确切换 | 从某个明确时刻开始,机场预报进入一套新的天气阶段 |
| `TEMPO` | 临时波动 | 一段短时、非整段主导的扰动窗口 |
| `BECMG` | 逐步转变 | 天气在该窗口内逐步过渡,不是立刻硬切 |
| `PROB30/40` | 30% / 40% 风险窗 | 有概率触发的扰动窗口,不等于确定发生 |
注意:
- `FM` 不是由 `valid_match` 触发,它是按 `FMddhhmm` 独立解析出来的明确切换段
- `PROB40` 不是“确定性信号”,仍然只是概率窗口
---
## 六、怎么理解“机场端压温风险偏高”
这句话的意思不是:
- 城区一定更冷
- 一定会结算更低
真正意思是:
**在机场这个结算相关站点上,峰值窗口附近更容易因为云、阵雨或雷暴,导致最终最高温冲不上去。**
对于很多按机场报文或机场相关站点结算的市场,这一点很关键。
一句话:
- `TAF` 提示压温高
- 不代表一定下雨
- 但代表“机场高温可能被压低”的概率更值得防
---
## 七、怎么和图表一起看
### 情况 A:模型还偏热,但 TAF 给出压温高
这表示:
1. 数值模型仍给出较高高温
2. 但机场端预报提示云雨/雷暴会打断峰值兑现
这种情况下,更适合理解成:
- **机场侧高温兑现有风险**
- 追更高温区间要谨慎
### 情况 B:TAF 没有新增压温,但总判断仍偏降温
这表示:
1. `TAF` 没有提供新的云雨压温利空
2. 但近地面窗口本身已经在走弱
比如:
- 温度走弱
- 风场切换
- 气压回升
- 露点回落
所以:
- `TAF 无压温`
- **不等于**
- `一定继续升温`
当前系统已经会在摘要里把这层关系解释清楚。
---
## 八、当前实现边界
这套 `TAF` 逻辑当前是**交易导向的轻量解码**,不是完整航空专业解码器。
当前做得到:
1. 识别主要时间片
2. 找出峰值窗口附近的扰动
3. 识别云雨压温和风向切换
4. 联动图表时间轴
5. 联动交易动作
当前还没有做:
1. 对全部 TAF 语法做完整航空级严格解释
2. 对每个时段都做完整的逐字段人工预报学解释
3. 把 TAF 当成温度主预测模型
---
## 九、产品口径总结
最重要的一句:
**在 PolyWeather 里,TAF 是“机场侧扰动确认层”,不是主模型,也不是结算源。**
它最值钱的地方是:
- 帮你判断峰值窗口附近,机场高温会不会被云雨、雷暴、低云或风向切换打断。
对交易来说,更适合把它理解成:
- “机场这边有没有额外的压温风险”
而不是:
- “TAF 直接告诉我今天结算温度是多少”
---
_PolyWeather 文档中心_
+6 -9
View File
@@ -1,6 +1,6 @@
# 技术债与工程待办(v1.5.1
最后更新:`2026-03-20`
最后更新:`2026-03-31`
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
@@ -29,8 +29,7 @@ flowchart TD
end
subgraph S["状态与概率"]
S1["SQLite dual -> sqlite 切换验收"]
S2["EMOS shadow -> primary 门禁稳定化"]
S1["EMOS shadow -> primary 门禁稳定化"]
end
A --> P
@@ -48,7 +47,7 @@ flowchart TD
- 钱包绑定支持浏览器钱包 + WalletConnect。
- 账户中心与 Pro 权限展示链路打通。
- 钱包异动支持独立频道路由。
- 运行态状态/缓存已支持 SQLite 渐进迁移
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口
- 轻量可观测性已上线(`/healthz``/api/system/status``/metrics`)。
- EMOS/CRPS 校准链路已上线 shadow 模式。
@@ -56,7 +55,6 @@ flowchart TD
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| SQLite 主读切换验收 | 仍处于 dual 过渡期 | 线上跑满 24-48 小时后切到 `sqlite` |
| EMOS 上线门禁 | 当前 `hold`,不能切 primary | 继续积累样本,重点压 `bucket_brier` |
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
@@ -78,7 +76,6 @@ flowchart TD
## 6. 下阶段里程碑
1. 完成 SQLite 从 `dual``sqlite` 的主读切换
2. 稳定 EMOS shadow,达到 rollout `observe/promote` 条件
3. 补外部监控抓取与告警阈值
4. 评估并推进支付合约 V2 升级。
1. 稳定 EMOS shadow,达到 rollout `observe/promote` 条件
2. 补外部监控抓取与告警阈值
3. 评估并推进支付合约 V2 升级
+6 -9
View File
@@ -1,6 +1,6 @@
# 技术债与工程待办(v1.5.1
最后更新:`2026-03-20`
最后更新:`2026-03-31`
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
@@ -29,8 +29,7 @@ flowchart TD
end
subgraph S["状态与概率"]
S1["SQLite dual -> sqlite 切换验收"]
S2["EMOS shadow -> primary 门禁稳定化"]
S1["EMOS shadow -> primary 门禁稳定化"]
end
A --> P
@@ -48,7 +47,7 @@ flowchart TD
- 钱包绑定支持浏览器钱包 + WalletConnect。
- 账户中心与 Pro 权限展示链路打通。
- 钱包异动支持独立频道路由。
- 运行态状态/缓存已支持 SQLite 渐进迁移
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口
- 轻量可观测性已上线(`/healthz``/api/system/status``/metrics`)。
- EMOS/CRPS 校准链路已上线 shadow 模式。
@@ -56,7 +55,6 @@ flowchart TD
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| SQLite 主读切换验收 | 仍处于 dual 过渡期 | 线上跑满 24-48 小时后切到 `sqlite` |
| EMOS 上线门禁 | 当前 `hold`,不能切 primary | 继续积累样本,重点压 `bucket_brier` |
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
@@ -78,7 +76,6 @@ flowchart TD
## 6. 下阶段里程碑
1. 完成 SQLite 从 `dual``sqlite` 的主读切换
2. 稳定 EMOS shadow,达到 rollout `observe/promote` 条件
3. 补外部监控抓取与告警阈值
4. 评估并推进支付合约 V2 升级。
1. 稳定 EMOS shadow,达到 rollout `observe/promote` 条件
2. 补外部监控抓取与告警阈值
3. 评估并推进支付合约 V2 升级
+27 -23
View File
@@ -2,23 +2,23 @@
## 执行摘要
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的“生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**,并进一步映射到市场报价完成**错价扫描**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 明确其“Open-Core”边界:仓库公开天气聚合、基础分析、看板、Bot、标准支付流程;生产私有部分包含商业风控、阈值与运营工具等
从工程实现看,截至 `2026-03-21`,项目已经完成一轮明确的工程化收口:多源天气采集仍保持现有业务能力,同时已完成采集层与 Web API 大文件拆分、CI 质量门禁、配置分级(`.env.example` / `.env.secrets.example` / 中文部署文档)、EMOS/CRPS 校准链路、运行态状态与缓存 SQLite 的渐进迁移,以及基础可观测性接口`/healthz``/api/system/status``/metrics`
这意味着报告里最初最突出的“工程地基缺失”问题,已经有一部分被关闭:`src/data_collection/weather_sources.py``web/app.py` 不再是原来的超大单文件;GitHub Actions 已覆盖 Python、前端和 Docker build;配置与密钥治理已成体系;运行态状态不再只能依赖 JSON/JSONL 文件;EMOS 也不再只是概念,而是进入了可训练、可评估、可 shadow、可门禁判断的阶段。
但项目仍处在“从可用走向稳态”的中段,而不是终局。当前真正的高优先级问题已收敛为三类:第一,**SQLite 迁移仍处于推荐的 dual 过渡模式**,线上真正切主读路径前仍需跑一段时间验证;第二,**可观测性只完成了轻量级指标层**,还没有形成完整的外部监控、阈值告警与趋势面板;第三,**EMOS 仍未达到生产切换标准**,当前门禁结论明确为 `hold`,阻塞原因是 shadow bucket brier 明显退化。支付链路方面,链下审计与容灾已明显增强:事件重放、SQLite 审计事件、RPC 多节点容灾、合约静态检查、`/ops` 支付异常单、按邮箱恢复脚本都已补齐;当前剩余风险主要集中在**链上合约本身仍是最小实现**,尚未升级到 SafeERC20、Pausable、链上套餐绑定等更强防护版本。
因此,当前阶段最正确的策略已经不是继续做“大范围基础重构”,而是围绕**迁移验收、可观测性补全、EMOS 上线门禁稳定化**这条线持续收口。短中期内更高 ROI 的方向依然不是引入新的大模型,而是把现有“采集→后处理→市场映射→支付/订阅”的链路做成**状态一致、指标可见、发布可控、回退明确**的生产平台。
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的“生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**,并进一步映射到市场报价完成**错价扫描**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外
从工程实现看,截至 `2026-04-03`,项目已经完成一轮明确的工程化收口:多源天气采集仍保持现有业务能力,同时已完成采集层与 Web API 大文件拆分、CI 质量门禁、配置分级(`.env.example` / `.env.secrets.example` / 中文部署文档)、EMOS/CRPS 校准链路、运行态状态与缓存迁移到 SQLite 主路径,以及最小外部监控链路`/healthz``/api/system/status``/metrics` + Prometheus + Alertmanager + Grafana + Telegram relay)。除此之外,项目还补上了**历史真值治理**:`daily_records` 继续只保留近 14 天运行态缓存,但新增了永久真值表、真值 revision 审计表和长期训练特征表,并开始把监督真值与训练特征从“短期缓存”正式拆到“长期可追溯存储”
这意味着报告里最初最突出的“工程地基缺失”问题,已经有一部分被关闭:`src/data_collection/weather_sources.py``web/app.py` 不再是原来的超大单文件;GitHub Actions 已覆盖 Python、前端和 Docker build;配置与密钥治理已成体系;运行态状态不再只能依赖 JSON/JSONL 文件;EMOS 也不再只是概念,而是进入了可训练、可评估、可 shadow、可门禁判断的阶段;更重要的是,监督真值与训练特征不再只能附着在 14 天运行态缓存上
但项目仍处在“从可用走向稳态”的中段,而不是终局。当前真正的高优先级问题已进一步收敛:**EMOS 仍未达到生产切换标准**,当前门禁结论明确为 `hold`,阻塞原因是 shadow bucket brier 明显退化,同时历史长期特征仍处在“刚开始积累”的阶段。SQLite 迁移方面,运行态主读切换和核心离线训练/回填链路已经完成验收:在移除 `data/*.json` / `data/*.jsonl` 后,训练、评估、shadow report 与关键 backfill 脚本仍可仅依赖运行时数据库正常执行;当前保留的 legacy 文件路径主要用于迁移、导出、校验和显式回退输入。历史真值治理方面,新增的永久真值表、revision 审计表与长期训练特征表已经落地,`Taipei` / `Shenzhen``Wunderground` 历史回填也已接通,因此当前缺口已从“历史真值是否会继续丢失”转为“历史特征是否能持续增长并支撑 EMOS/LGBM 评估”。可观测性方面,最小外部监控链路已经补齐:Prometheus 抓取、Alertmanager 规则、Grafana 面板、Telegram 告警 relay 与巡检脚本均已落地;当前剩余缺口已从“有没有外部监控”转为“监控覆盖深度是否足够”,例如节点级资源、数据库体积趋势、支付细粒度指标、按城市/来源拆分的业务 SLA。支付链路方面,链下审计与容灾已明显增强:事件重放、SQLite 审计事件、RPC 多节点容灾、合约静态检查、`/ops` 支付异常单都已补齐;当前剩余风险主要集中在**链上合约本身仍是最小实现**,尚未升级到 SafeERC20、Pausable、链上套餐绑定等更强防护版本。
因此,当前阶段最正确的策略已经不是继续做“大范围基础重构”,而是围绕**EMOS 上线门禁稳定化、长期训练特征持续积累、监控覆盖深挖、支付合约防护升级**这条线持续收口。短中期内更高 ROI 的方向依然不是引入新的大模型,而是把现有“采集→后处理→市场映射→支付/订阅”的链路做成**状态一致、指标可见、发布可控、回退明确**的生产平台。
## 项目概览
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
项目主功能可归纳为四层:
**天气层(数据源/采集)**:聚合 20 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报)、美国 NWS(仅美国城市)、以及部分城市使用官方结算源(香港 HKO、台北 CWA)等。
**天气层(数据源/采集)**:聚合 39 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报)、美国 NWS(仅美国城市)、以及部分城市使用官方结算源(香港 HKO、台北 RCSS/Wunderground、深圳 ZGSZ/Wunderground)等。
**分析层(DEB/趋势/概率/结算口径)**:
DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;同时维护 `daily_records.json` 做历史对账、命中率/MAE 统计,并支持基于 WUWeather Underground 口径)四舍五入的结算命中评估。
DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;运行态仍维护近 14 天 `daily_records` 缓存做当前对账,但长期监督真值与训练特征已经迁到 SQLite 永久表中,并支持基于 WUWeather Underground 口径)四舍五入的结算命中评估。
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
**市场层(Polymarket 行情对照)**:只读模式从 Gamma API 发现市场、从 CLOB`py-clob-client` 或 REST 回退)读取价格/盘口并计算 edge(模型概率 − 市场概率)生成信号标签。
**商业化与支付**:订阅(`Pro Monthly 5 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
**许可证**:仓库根目录 `LICENSE` 为 MIT。 同时 README 强调 Open-Core 策略与生产私有组件边界,意味着“可复现/可审计”的范围以公开部分为准
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权
(插图:项目 README 中包含产品截图,可用于快速理解信息架构与 UI 形态)
![PolyWeather demo map](https://raw.githubusercontent.com/yangyuan-zhen/PolyWeather/main/docs/images/demo_map.png)
@@ -37,7 +37,7 @@ DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
| Python 域模块 | `src/analysis/probability_calibration.py` + `src/analysis/probability_rollout.py` | 概率校准与上线门禁 | 已支持 `legacy / emos_shadow / emos_primary`,并可产出 rollout 判断。 |
| Python 域模块 | `src/payments/*` + `contracts/*` | 支付合约 + 事件监听/补单 | Solidity 合约 + Python 侧事件扫描/确认循环 + SQLite 审计事件 + RPC 多节点容灾 + 合约静态检查。 |
| Python 域模块 | `src/auth/*``docs/SUPABASE_SETUP_ZH.md``scripts/supabase/schema.sql` | Supabase 鉴权/订阅/积分 | 使用 `/auth/v1/user` 校验 JWT、`/rest/v1/subscriptions` 查订阅(服务端角色 key 必须保密)。 |
| Python 域模块 | `src/database/runtime_state.py` | 运行态状态与缓存仓储 | 已接入 `daily_records``telegram_alert_state``probability_training_snapshots``open_meteo` 持久缓存。 |
| Python 域模块 | `src/database/runtime_state.py` | 运行态状态、永久真值与训练特征仓储 | 已接入 `daily_records``telegram_alert_state``probability_training_snapshots``open_meteo` 持久缓存,并新增永久真值表、真值修订审计表、长期训练特征表。 |
| 工程与运维 | `docker-compose.yml``Dockerfile``.github/workflows/ci.yml``scripts/*` | 部署/验证脚本 | 现已具备 CI 门禁、迁移脚本、状态校验脚本、配置校验脚本与 rollout 报告脚本。 |
### 参考架构与关键工作流
@@ -58,7 +58,8 @@ flowchart TB
WX[WeatherDataCollector]
CITY[CITY_REGISTRY]
HIST[(SQLite runtime state<br/>daily_records / cache / snapshots)]
JSON[Legacy JSON files<br/>dual-mode fallback]
TRUTH[(SQLite truth tables<br/>truth_records / revisions / features)]
JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
end
subgraph ExternalAPIs
@@ -102,8 +103,10 @@ flowchart TB
RPC --> SOL
WX --> HIST
WX --> TRUTH
WX --> JSON
FAST --> HIST
FAST --> TRUTH
WX --> CITY
```
@@ -122,7 +125,7 @@ flowchart TB
**推理流水线(在线)**
Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。
**检查点(checkpoints**:传统 ML checkpoint 不适用;但项目现已形成两类“业务状态 checkpoint”:
aSQLite 运行态存储(推荐主路径);(blegacy JSON/JSONL 文件(迁移期回退路径)。当前设计 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`建议先以 `dual` 运行,再切到 `sqlite`
aSQLite 运行态存储(当前线上与核心离线链路主路径);(b)SQLite 永久真值/训练特征表(当前监督真值与训练样本长期主存);(c)legacy JSON/JSONL 文件(主要保留给迁移回滚、导出比对与显式回退输入)。当前设计仍支持 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`但对线上部署与离线训练/回填而言,推荐目标状态都已经是 `sqlite`
### 测试、CI/CD 与运维验证
**测试**:仓库存在 `tests/test_trend_engine.py`,覆盖 μ 计算、死盘判定、预报崩盘提示、趋势方向等核心逻辑(通过 patch 隔离外部依赖)。
@@ -142,12 +145,13 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
**核心文件过大问题已明显缓解,但边界仍需继续稳定**:`WeatherDataCollector``web/app.py` 的超大文件问题已完成第一阶段拆分;当前风险已从“文件过大”转为“跨模块兼容与边界稳定性”,例如旧调用路径、兼容导出、跨层 helper 仍需持续清理。
**可复现性已从“缺模板”进入“模板与生产对齐”的阶段**:`.env.example``.env.secrets.example`、中文配置文档、前端部署文档、运行时配置校验器都已存在;当前风险主要在于线上历史 `.env` 与新模板并存、旧变量命名残留、以及密钥轮换与分层是否真正落实。
**CI 已建立,但组织级质量门禁未必完全收口**:CI 现已覆盖 Python、前端与 Docker build。当前问题不再是“缺 CI”,而是是否把这些 status check 绑定到 `main` 保护策略,以及是否逐步引入更严格的 pre-merge 审查。
**运行态状态/缓存迁移仍处于过渡期**`daily_records``telegram_alert_state``probability_training_snapshots``open_meteo` 缓存已经支持 SQLite并有迁移/校验脚本;但在正式切到 `sqlite` 主读路径前,仍需经历一段 dual 双写验证期。这是当前最需要谨慎处理的工程性风险之一
**运行态状态/缓存与核心离线链路的 SQLite 收口已完成**`daily_records``telegram_alert_state``probability_training_snapshots``open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入
**历史真值治理已从设计缺陷修复到可追溯运行**:`daily_records` 继续作为近 14 天运行态缓存,但已经不再承担长期监督真值职责;项目新增了永久真值表、真值 revision 审计表和长期训练特征表,并为 `Taipei` / `Shenzhen` 补上了 `Wunderground` 历史回填链路。当前风险已不再是“监督真值会不会继续被 14 天裁剪吞掉”,而是“历史长期特征能否持续积累到足够支撑 EMOS/LGBM 重新评估”。
**第三方服务合规与稳定性风险**
项目强依赖外部 APIOpen-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。
**可观测性已起步,但仍不构成完整监控体系**:项目现在已有 `/healthz``/api/system/status``/metrics`,并为 HTTP 与关键第三方源增加了轻量指标;但仍缺少 Prometheus/Grafana 级别的外部抓取、告警阈值、趋势面板和运日报。这部分现在属于“已开始,不算完成”。
**可观测性最小闭环已完成,但监控深度仍待加强**:项目现在已有 `/healthz``/api/system/status``/metrics`,并已补齐 Prometheus 抓取、Alertmanager 规则、Grafana 面板、Telegram relay 与巡检脚本。与此同时,`/ops` 已经逐步演进为后台管理台而不只是状态页:除支付、会员、用户与 EMOS 门禁外,还新增了训练数据治理卡片、城市覆盖矩阵,以及 `/ops/truth-history` 这种可直接查询 `actual_high / settlement_source / station_code / truth_version / updated_by / updated_at` 的真值表浏览页。当前缺口不再是“有没有外部监控”,而是节点级资源、数据库体积趋势、更细粒度支付指标、按城市/来源拆分的业务 SLA,以及是否需要进一步补 `truth revision` 明细页、趋势和运日报。
**EMOS 已完成工程接入,但未完成生产发布**:EMOS/CRPS 校准、shadow 观测、rollout report、上线门禁都已实现;当前真实门禁结果为 `hold`,阻塞原因是 shadow bucket brier 明显退化。因此概率引擎标准化并非未做,而是“工程完成、发布未通过”。
**许可证/商业使用的潜在冲突点**:仓库自身是 MIT,但如果未来尝试引入外部 AI 预报模型,需要非常谨慎GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
**许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部 AI 预报模型,仍需单独核验第三方代码与权重的商用条件GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
## 对标分析
为满足“至少 3 个相似开源项目或近期论文”对标,本报告选择三类代表:
@@ -158,7 +162,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
| 项目/论文 | 解决的问题 | 输出形态 | 性能/效果(公开描述) | 易用性与依赖 | 许可证要点 |
| --------------------------------------------------------- | ---------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| **PolyWeather**(本仓库) | 温度结算市场气象情报:多源→概率桶→错价扫描→订阅/支付 | 生产级应用(Web+Bot+API+支付) | 以工程能力为主;内置 DEB、概率桶、死盘判定、市场扫描;覆盖 20 城市。 | 主要依赖外部 APIDocker Compose 一键启动。 | 仓库 MITOpen-Core(部分生产规则私有)。 |
| **PolyWeather**(本仓库) | 温度结算市场气象情报:多源→概率桶→错价扫描→订阅/支付 | 生产级应用(Web+Bot+API+支付) | 以工程能力为主;内置 DEB、概率桶、死盘判定、市场扫描;当前覆盖 39 城市,并已补齐真值治理与后台运维视图。 | 主要依赖外部 APIDocker Compose 一键启动。 | 仓库 `AGPL-3.0-only`;品牌、生产私有数据与运营规则不随代码许可证授权。 |
| **GraphCast**google-deepmind/graphcast | 10 天全球中期预报(ML 替代/增强 NWP) | 模型代码+权重+notebooks | 论文与介绍提到在大量指标上优于主流确定性系统;仓库提供预训练权重与示例数据入口,并提示 ERA5/HRES 数据条款需另行遵守。 | 完整训练需 ERA5 等;更适合科研/平台级推理,不是产品级 BFF。 | 代码 Apache-2.0;权重 CC BY-NC-SA 4.0(商业限制)。 |
| **FourCastNet**NVlabs/FourCastNet | 高分辨率 data-driven 全球预报(AFNO/ViT) | 模型训练/推理代码+数据/权重链接 | README 描述:0.25° 分辨率、周尺度推理非常快,并可做大规模集合;适合平台型预报。 | 训练/数据依赖大(ERA5 子集 TB 级);工程集成成本高。 | BSD 3-Clause(代码)。 |
| **Pangu-Weather**198808xc/Pangu-Weather + Nature 论文) | 3D Transformer 架构的中期全球预报 | ONNX 推理代码+预训练模型 | Nature 论文称在 reanalysis 上对比 IFS 有更强确定性预报表现,并强调速度优势;仓库提供 ONNX 推理与 lite 版训练说明。 | 模型文件大(多份 ~GB 级),训练资源需求高;更适合科研推理或内部平台。 | 权重 BY-NC-SA 4.0、明确禁止商业用途。 |
@@ -169,12 +173,12 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
**对标结论**PolyWeather 与这类“全球 AI 预报模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
## 优先级改进建议
下表按截至 `2026-03-21` 的真实状态重排优先级。已完成项不再继续列为“待做”,只保留当前仍需推进的事项。
下表按截至 `2026-04-03` 的真实状态重排优先级。已完成项不再继续列为“待做”,只保留当前仍需推进的事项。
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 高 | **完成 SQLite 迁移切换与验收**:从 `dual` 过渡到 `sqlite` 主读路径 | 25 天 | 真正关闭 JSON/JSONL 并发一致性风险;状态/缓存统一入库 | 迁移校验不充分会导致线上行为漂移 | 1) 线上部署新代码 → 2) 执行迁移与校验脚本 → 3) `dual` 运行至少 24–48 小时 → 4) 校验 `/api/history`、bot 告警、snapshot、缓存都正常 → 5) 再切 `POLYWEATHER_STATE_STORAGE_MODE=sqlite` |
| 高 | **把轻量可观测性接入外部监控与告警**:围绕 `/metrics` 建立抓取、阈值与巡检 | 3–7 天 | 不再只靠日志定位问题;可以监控第三方源错误率、缓存命中与 HTTP 延迟 | 指标不分层会导致噪音高、告警无用 | 1) 抓取 `/metrics` → 2) 先围绕 HTTP、Open-Meteo、MGM、METAR 建立最小仪表板 → 3) 为 429/403/error/stale_cache 设阈值 → 4) 增加巡检脚本或告警通道 |
| 高 | **稳定 EMOS shadow 并收紧上线门禁** | 1–2 周 | 让概率引擎升级具备明确发布条件,避免拍脑袋切换 | 当前 shadow bucket brier 退化明显,存在误上线风险 | 1) 持续积累 snapshot 样本 → 2) 定期重训与生成 `evaluation_report` / `shadow_report` / `rollout_report` → 3) 重点压 `bucket_brier` 退化 → 4) 只有门禁从 `hold` 进入 `observe/promote` 后才考虑上线 |
| 高 | **持续积累长期训练特征,验证 SQLite 真值治理后的样本增长** | 12 周 | 让 EMOS/LGBM 的重训真正建立在长期可信样本上,而不是继续被短期特征缺口卡住 | 当前真值已长期化,但历史长期特征仍偏少,EMOS/LGBM 样本增长会滞后 | 1) 持续写入 `training_feature_records_store` → 2) 每日检查 `/ops` 训练数据与 `/ops/truth-history` → 3) 定期对 `Taipei` / `Shenzhen` 的 Wunderground 回填做抽查 → 4) 观察样本是否自然增长后再重训 |
| 中 | **把最小外部监控继续补深**:从“可告警”提升到“可运营” | 3–7 天 | 不再只知道服务坏没坏,还能看资源趋势、来源 SLA 和支付波动 | 指标过多会带来维护噪音 | 1) 增加节点 CPU/内存/磁盘 → 2) 增加 SQLite/支付体积与事件趋势 → 3) 把 HTTP/来源指标细分到城市/来源维度 → 4) 增加日报或异常摘要 |
| 中 | **市场层升级为 async + 类型安全**:引入 `aiopolymarket` 或在现有层加重试/backoff/连接池 | 4–7 天 | 行情层更稳,减少短时网络抖动;更易扩展更多市场/分页 | 依赖升级带来的行为差异 | 1) 把 requests.Session 替换为 aiohttp/httpx → 2) 在 Gamma/CLOB 调用侧实现指数退避 → 3) 引入 typed models,减少解析失败 |
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 1–2 周 | 在已完成的链下审计与容灾之上,进一步收紧链上授权边界 | 合约升级需要重新部署、迁移配置并再次验证 | 1) 维持现有事件重放、SQLite 审计、多 RPC fallback → 2) 升级合约到 SafeERC20 + Pausable → 3) 评估链上 plan/amount/token 绑定或 EIP-712 签名校验 → 4) 迁移后更新 PolygonScan 验证与支付审计文档 |
| 中 | **将 CI 与分支保护/发布流程真正绑定** | 1–3 天 | 让现有 CI 从“存在”变成“强制门禁” | 历史分支/热修流程可能受影响 | 1) GitHub `main` 开启 required checks → 2) 把 release/tag 流程绑定 CI → 3) 明确热修例外流程 |
@@ -193,8 +197,8 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
### 气象预测与概率校准基准
**数据集**(建议从现有生产数据演进)
1`daily_records.json` 的历史快照:已包含多模型预报、`actual_high``deb_prediction``mu` 与概率快照字段,天然可转成评测数据(建议迁移到 DB 后做版本化导出)
2)观测“真值”统一口径:对 METAR 城市用 AviationWeather Data API;对香港/台北等按结算源(HKO/CWA作为真值,和项目当前逻辑一致。
1`truth_records_store + training_feature_records_store` 的长期样本:当前长期评测主源应优先来自永久真值表与长期训练特征表;legacy 的 `daily_records.json``settlement_history.json` 更适合作为迁移恢复与对照来源,而不是长期主输入
2)观测“真值”统一口径:对 METAR 城市用 AviationWeather Data API;对香港按 HKO、对台北/深圳按 `Wunderground RCSS/ZGSZ` 等结算源作为真值,和项目当前逻辑一致。
**指标**
1)确定性误差:MAE、RMSE(按城市、按季节、按风险等级分组);
2)结算命中率:`WU_round(pred) == WU_round(actual)`(项目已有统计口径);
@@ -210,7 +214,7 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
- 若历史样本足够,DEB 应在“系统性偏差明显”的城市提升 MAE;
- EMOS 类方法通常能在概率校准(可靠性与 CRPS)上更稳定,尤其当 ensemble 信息可用(项目已接入 Open-Meteo ensemble/p10/p90)。
**算力**:以上评测全部可在 CPU 上完成;数据量按“20 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
**算力**:以上评测全部可在 CPU 上完成;数据量按“39 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
### 错价信号与市场有效性基准
**数据集**
@@ -234,7 +238,7 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
| ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| 第 1–2 周 | 工程地基:CI + 规范 + 配置可复现 | GitHub Actionsruff/eslintpytest 可一键跑;`.env.example`;敏感项分级说明(尤其 Supabase service role key 不可暴露)。 | 后端为主;前端补 eslint/typecheck |
| 第 3–5 周 | 核心模块解耦:采集 Provider 化 + API 分层 | provider 接口与实现;`web/app.py` 拆分路由与服务;核心 schema(Pydantic) | 风险:行为漂移;用回放测试压住 |
| 第 6–8 周 | 状态/缓存统一 + 可观测性 | `daily_records/open_meteo_cache` 迁移 DB;指标(请求量/429/延迟/命中率);告警阈值 | 可先用 SQLite/Redis,后续再上 Postgres |
| 第 6–8 周 | 状态/缓存统一 + 可观测性 | `daily_records/open_meteo_cache` 主读 SQLite;离线脚本也切到 SQLite 优先;永久真值表 / revision / 长期训练特征表落地;指标(请求量/429/延迟/命中率);Prometheus/Alertmanager/Grafana 最小链路与告警阈值 | 可先用 SQLite/Redis,后续再上 Postgres |
| 第 9–10 周 | 评测体系上线 | 离线评测脚本(MAE/RMSE/WU-hit/Brier/CRPS);日报/周报自动生成 | 直接基于项目现有字段扩展 |
| 第 11–12 周 | 概率引擎升级(可选)+ 市场层健壮性增强 | EMOS/CRPS 拟合的 shadow 输出;Gamma/CLOB 客户端增强(async、重试、分页) | 以“小步可回滚”为原则,避免一次性替换 |
@@ -244,7 +248,7 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
**密钥泄露与权限滥用**:Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。
**支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。当前项目已经补齐“事件监听 + 确认补单”双路径、事件重放脚本、SQLite 审计事件与多 RPC fallback;现阶段的主要剩余风险不再是“没有防护”,而是链上合约仍为最小实现,owner 为单地址管理,且没有 pause 开关与 SafeERC20。
**引入外部 AI 预报模型的商业合规风险**GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
**Open-Core 边界导致的“公开仓库与生产行为不一致”**:README 明确生产存在私有风控与阈值。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
**代码公开与生产私有资产边界导致的“公开仓库与生产行为不一致”**:README 明确品牌、商标、生产私有数据与运营阈值不在代码许可证授权范围内。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
## 参考链接
- PolyWeather 仓库(本次评估对象):https://github.com/yangyuan-zhen/PolyWeather
+29 -20
View File
@@ -1,26 +1,32 @@
# PolyWeather 侧边栏插件(MVP
# PolyWeather Side Panel
这是一个 Chrome / Edge 侧边栏扩展的 MVP,用于把 PolyWeather 右侧城市卡片移植到浏览器侧边栏
`PolyWeather Side Panel` 是一个面向天气交易场景的 Chrome / Edge 浏览器侧边栏工具
## 功能
- 侧边栏展示:
- 城市选择
- 风险徽章
- 城市档案(结算源 / 距离 / 观测更新时间 / 周边站点)
- 今日日内走势(简版 Canvas)
- 多日预报
- 快捷按钮:
- 今日日内分析
- 历史对账
- 打开完整网站分析
- 自动识别城市:
- 监听当前激活标签页 URL(例如 Polymarket `.../event/highest-temperature-in-ankara-...`
- 自动将侧边栏城市切换为 URL 对应城市
- 设置页可配置:
- 网站基础地址
- API 基础地址
- Bearer Token(可选)
1. 自动识别当前 Polymarket 页面中的城市,也支持手动切换。
2. 展示城市档案:结算站点、站点距离、观测更新时间、周边站点数量。
3. 展示今日日内走势(简版):`DEB` 走势与官方观测(`METAR / HKO / CWA / NOAA`)对照,可悬停查看时间与温度。
4. 展示多日最高温预报(简版),当前以 `DEB` 优先。
5. 支持一键刷新,强制拉取最新温度数据。
6. 支持本地缓存,提升打开速度;刷新时自动更新缓存。
7. 支持一键跳转到完整网站分析页面。
## 数据说明
- 香港使用 `HKO`(香港天文台)结算源。
- 其他城市按配置使用 `METAR / NOAA / 官方数据源`
- 城市展示名以主站返回值为准,例如 `aurora` 市场在插件中会显示为 `Denver`
## 权限说明
- `tabs`:用于识别当前活动标签页 URL 并自动匹配城市。
- `storage`:用于保存插件配置与本地缓存,仅存储在本地浏览器。
- `sidePanel`:用于在浏览器侧边栏展示界面。
## 隐私说明
本扩展不要求用户登录,不收集个人身份信息,不上传浏览历史,仅在必要时请求天气接口数据以完成展示功能。
## 本地安装(开发者模式)
@@ -42,5 +48,8 @@
## 说明
- 当前版本是 MVP,重点是“导流回站”,未接入支付链路。
- 当前版本仍是轻量产品,重点是“监控 + 基础判断 + 导流回站”,未接入支付链路。
- 若你的 API 做了严格鉴权,请先在设置页填写 token 再使用。
- 插件走势图与主站保持一致:`Wunderground` 结算城市不再单独绘制结算参考线,统一显示机场 `METAR` / 官方观测点位。
- 点击“打开网站查看更多”会回到主站继续查看完整分析。
- 插件不会承载完整分析;完整结构判断、历史对账和更多信号仍以主站为准。
+2 -2
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "PolyWeather Side Panel",
"description": "PolyWeather 右侧城市卡片(浏览器侧边栏)",
"version": "0.1.3",
"description": "Weather side panel for Polymarket.",
"version": "0.1.9",
"icons": {
"16": "icon-16.png",
"32": "icon-32.png",
+85
View File
@@ -253,6 +253,84 @@ body {
font-size: 12px;
}
.decision-card {
display: grid;
gap: 10px;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 10px;
}
.decision-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.decision-direction {
font-size: 16px;
font-weight: 800;
line-height: 1.2;
color: #f8fbff;
}
.decision-window {
margin-top: 4px;
color: var(--muted);
font-size: 11px;
}
.decision-confidence {
flex: 0 0 auto;
padding: 5px 9px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
}
.decision-confidence.high {
color: #86efac;
border-color: rgba(52, 211, 153, 0.45);
background: rgba(52, 211, 153, 0.12);
}
.decision-confidence.medium {
color: #fcd34d;
border-color: rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.12);
}
.decision-confidence.low,
.decision-confidence.neutral {
color: #cbd5e1;
border-color: rgba(148, 163, 184, 0.28);
background: rgba(148, 163, 184, 0.1);
}
.decision-summary {
color: #dbeafe;
font-size: 12px;
line-height: 1.45;
}
.decision-reasons {
margin: 0;
padding-left: 18px;
display: grid;
gap: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.decision-reasons li {
margin: 0;
}
.forecast-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -293,6 +371,13 @@ body {
background: rgba(59, 130, 246, 0.15);
}
.open-full-hint {
margin-bottom: 10px;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.error {
border: 1px solid rgba(248, 113, 113, 0.45);
border-radius: 12px;
+16
View File
@@ -59,12 +59,28 @@
<div id="chartLegend" class="legend-text">--</div>
</section>
<section class="section">
<h3 id="decisionTitle">方向判断</h3>
<div class="decision-card">
<div class="decision-top">
<div>
<div id="decisionDirection" class="decision-direction">--</div>
<div id="decisionWindow" class="decision-window">--</div>
</div>
<div id="decisionConfidence" class="decision-confidence neutral">--</div>
</div>
<div id="decisionSummary" class="decision-summary">--</div>
<ul id="decisionReasons" class="decision-reasons"></ul>
</div>
</section>
<section class="section">
<h3 id="forecastTitle">多日预报</h3>
<div id="forecastRow" class="forecast-row"></div>
</section>
<section class="section">
<div id="openFullHint" class="open-full-hint"></div>
<button id="openFullBtn" class="btn btn-open-full">打开完整网站分析</button>
</section>
+244 -24
View File
@@ -4,7 +4,7 @@ const DEFAULT_CONFIG = {
selectedCity: "",
siteBase: "https://polyweather-pro.vercel.app"
};
const CACHE_VERSION = "v1";
const CACHE_VERSION = "v2";
const locale = String(navigator.language || "en").toLowerCase().startsWith("zh")
? "zh"
: "en";
@@ -20,7 +20,8 @@ const I18N = {
settlementAirport: "结算机场",
hko: "香港天文台 (HKO)",
cwa: "交通部中央气象署 (CWA)",
noaa: "NOAA RCTP(台湾桃园国际机场)",
noaa: "NOAA 官方时序",
wunderground: "Wunderground 结算站",
city: "城市",
refresh: "刷新数据",
cityProfile: "城市档案",
@@ -28,17 +29,19 @@ const I18N = {
obsUpdate: "观测更新",
nearbyStations: "周边站点",
intradayTrend: "今日日内走势(简版)",
directionTitle: "方向判断",
forecast: "多日预报",
openFull: "打开完整网站分析",
openFull: "打开网站查看更多",
openFullHint: "插件只提供基础判断;更多分析请到网站查看。",
noTrendData: "暂无趋势数据",
noForecast: "暂无多日预报",
noContinuousObs: "暂无连续观测",
nearbyMonitoringSuffix: "个参与监控",
today: "今天",
omSeries: "OM预测",
noaaSettlementRef: "NOAA RCTP 结算参考",
noaaSettlementRef: "NOAA 结算参考",
noaaSettlementLegend:
"台北按 NOAA RCTP 最终完成质控后的最高整度摄氏值结算;图中曲线仅作结算参考。",
"该城市按 NOAA 最终完成质控后的最高整度读数结算;图中曲线仅作结算参考。",
loadCityDetailFailed: "加载城市详情失败",
refreshFailed: "刷新温度数据失败",
initFailed: "初始化失败",
@@ -46,7 +49,27 @@ const I18N = {
publicModeHint: "公开模式只需配置 API BaseToken 可留空。",
freshnessRecent: "数据约 {minutes} 分钟前更新。",
freshnessWarn: "数据已 {minutes} 分钟未更新,建议点右上角刷新。",
freshnessStale: "数据已 {minutes} 分钟未更新,当前结果可能偏旧,请立即刷新。"
freshnessStale: "数据已 {minutes} 分钟未更新,当前结果可能偏旧,请立即刷新。",
directionWarmer: "未来偏升温",
directionCooler: "未来偏降温",
directionFlat: "方向不清",
confidenceHigh: "高置信",
confidenceMedium: "中置信",
confidenceLow: "低置信",
confidenceNeutral: "待观察",
decisionWindow: "未来约 {hours} 小时",
reasonDebAbove: "DEB 路径高于当前实测,未来窗口上沿约 {delta}{symbol}。",
reasonDebBelow: "DEB 路径低于当前实测,未来窗口下沿约 {delta}{symbol}。",
reasonObsAboveOm: "当前实测高于 OM 基线约 {delta}{symbol},短时偏暖。",
reasonObsBelowOm: "当前实测低于 OM 基线约 {delta}{symbol},短时偏冷。",
reasonNearPeak: "当前已接近日内高点,继续上冲空间有限。",
reasonRoomToPeak: "当前距日内预测高点仍有约 {delta}{symbol} 空间。",
reasonRangeWide: "未来窗口振幅偏大,波动仍可能反复。",
reasonRangeTight: "未来窗口振幅较小,更像缓慢推进而非急变。",
reasonNoObs: "当前连续实测偏少,方向判断主要依赖短窗预测。",
decisionSummaryWarmer: "未来几小时更可能缓慢抬升,板面更容易向高温侧移动。",
decisionSummaryCooler: "未来几小时更可能回落,板面更容易向低温侧移动。",
decisionSummaryFlat: "未来几小时更像震荡整理,短时升降温方向不够清晰。"
},
en: {
loadingWeather: "Loading weather data...",
@@ -59,7 +82,8 @@ const I18N = {
settlementAirport: "Settlement Airport",
hko: "Hong Kong Observatory (HKO)",
cwa: "Central Weather Administration (CWA)",
noaa: "NOAA RCTP (Taiwan Taoyuan International Airport)",
noaa: "NOAA official timeseries",
wunderground: "Wunderground settlement station",
city: "City",
refresh: "Refresh data",
cityProfile: "City Profile",
@@ -67,17 +91,19 @@ const I18N = {
obsUpdate: "Observation Update",
nearbyStations: "Nearby Stations",
intradayTrend: "Today's Intraday Trend",
directionTitle: "Direction Bias",
forecast: "Forecast",
openFull: "Open Full Site Analysis",
openFull: "Open Website for More",
openFullHint: "The extension only provides a basic bias. Visit the site for more analysis.",
noTrendData: "No trend data available",
noForecast: "No multi-day forecast",
noContinuousObs: "No continuous observations",
nearbyMonitoringSuffix: " stations monitored",
today: "Today",
omSeries: "OM Forecast",
noaaSettlementRef: "NOAA RCTP Settlement Reference",
noaaSettlementRef: "NOAA Settlement Reference",
noaaSettlementLegend:
"Taipei settles on NOAA RCTP using the finalized highest rounded whole-degree Celsius reading; the plotted line is only a settlement reference.",
"This city settles on NOAA using the finalized highest rounded reading; the plotted line is only a settlement reference.",
loadCityDetailFailed: "Failed to load city detail",
refreshFailed: "Failed to refresh weather data",
initFailed: "Initialization failed",
@@ -85,7 +111,27 @@ const I18N = {
publicModeHint: "In public mode only API Base is required; Token can be empty.",
freshnessRecent: "Data updated about {minutes} min ago.",
freshnessWarn: "Data is {minutes} min old. Consider refreshing.",
freshnessStale: "Data is {minutes} min old and may be stale. Refresh now."
freshnessStale: "Data is {minutes} min old and may be stale. Refresh now.",
directionWarmer: "Bias Warmer",
directionCooler: "Bias Cooler",
directionFlat: "Direction Unclear",
confidenceHigh: "High Conviction",
confidenceMedium: "Medium Conviction",
confidenceLow: "Low Conviction",
confidenceNeutral: "Watch",
decisionWindow: "Next ~{hours}h",
reasonDebAbove: "DEB path sits above current observation, with about {delta}{symbol} upside in the window.",
reasonDebBelow: "DEB path sits below current observation, with about {delta}{symbol} downside in the window.",
reasonObsAboveOm: "Current observation runs about {delta}{symbol} above the OM baseline, keeping the short-term tone warmer.",
reasonObsBelowOm: "Current observation runs about {delta}{symbol} below the OM baseline, keeping the short-term tone cooler.",
reasonNearPeak: "Current temperature is already close to the intraday peak, limiting further upside.",
reasonRoomToPeak: "There is still about {delta}{symbol} room to the projected intraday peak.",
reasonRangeWide: "The next-window range is wide, so the path can still swing around.",
reasonRangeTight: "The next-window range is tight, pointing to a gradual move rather than a sharp break.",
reasonNoObs: "Continuous observations are sparse, so the bias relies more on the short-window forecast.",
decisionSummaryWarmer: "The next few hours are more likely to drift warmer, so the board should lean toward the hotter side.",
decisionSummaryCooler: "The next few hours are more likely to ease lower, so the board should lean toward the cooler side.",
decisionSummaryFlat: "The next few hours look more range-bound, so there is no clear temperature direction yet."
}
};
@@ -121,6 +167,7 @@ const els = {
forecastRow: document.getElementById("forecastRow"),
errorBox: document.getElementById("errorBox"),
openFullBtn: document.getElementById("openFullBtn"),
openFullHint: document.getElementById("openFullHint"),
loadingOverlay: document.getElementById("loadingOverlay"),
loadingText: document.getElementById("loadingText"),
cityLabel: document.getElementById("cityLabel"),
@@ -129,8 +176,13 @@ const els = {
obsTimeLabel: document.getElementById("obsTimeLabel"),
nearbyLabel: document.getElementById("nearbyLabel"),
trendTitle: document.getElementById("trendTitle"),
forecastTitle: document.getElementById("forecastTitle")
,
decisionTitle: document.getElementById("decisionTitle"),
decisionDirection: document.getElementById("decisionDirection"),
decisionWindow: document.getElementById("decisionWindow"),
decisionConfidence: document.getElementById("decisionConfidence"),
decisionSummary: document.getElementById("decisionSummary"),
decisionReasons: document.getElementById("decisionReasons"),
forecastTitle: document.getElementById("forecastTitle"),
freshnessHint: document.getElementById("freshnessHint")
};
@@ -204,6 +256,37 @@ function getCityAliasTokens(rawCityName) {
aliases.add("buenos-aires");
aliases.add("buenosaires");
}
if (normalized === "aurora") {
aliases.add("denver");
aliases.add("denver-co");
aliases.add("buckley");
aliases.add("kbkf");
}
if (normalized === "los angeles") {
aliases.add("los-angeles");
aliases.add("lax");
aliases.add("klax");
}
if (normalized === "san francisco") {
aliases.add("san-francisco");
aliases.add("sfo");
aliases.add("ksfo");
}
if (normalized === "austin") {
aliases.add("aus");
aliases.add("kaus");
}
if (normalized === "houston") {
aliases.add("hou");
aliases.add("hobby");
aliases.add("khou");
}
if (normalized === "mexico city") {
aliases.add("mexicocity");
aliases.add("ciudad-de-mexico");
aliases.add("cdmx");
aliases.add("mmmx");
}
return [...aliases].filter((item) => item && item.length >= 2);
}
@@ -390,6 +473,7 @@ function riskText(level) {
function getSettlementSourceDisplay(detail) {
const source = String(detail?.current?.settlement_source || "").toLowerCase();
const sourceLabel = String(detail?.current?.settlement_source_label || "").trim();
if (source === "hko") {
return {
label: t("settlementSource"),
@@ -408,6 +492,14 @@ function getSettlementSourceDisplay(detail) {
value: t("noaa")
};
}
if (source === "wunderground") {
const stationLabel = sourceLabel || t("wunderground");
const station = String(detail?.current?.station_code || detail?.risk?.icao || "").trim();
return {
label: t("settlementSource"),
value: station ? `${stationLabel} (${station})` : stationLabel
};
}
const airport = detail?.risk?.airport || "--";
const icao = detail?.risk?.icao ? ` (${detail.risk.icao})` : "";
return {
@@ -480,7 +572,9 @@ function parseTimeToMinute(value) {
}
function getObservationRows(detail) {
const obsSource = Array.isArray(detail?.settlement_today_obs) && detail.settlement_today_obs.length
const sourceCode = String(detail?.current?.settlement_source || "").toLowerCase();
const useSettlementSource = sourceCode && sourceCode !== "wunderground";
const obsSource = useSettlementSource && Array.isArray(detail?.settlement_today_obs) && detail.settlement_today_obs.length
? detail.settlement_today_obs
: Array.isArray(detail?.metar_today_obs)
? detail.metar_today_obs
@@ -759,12 +853,129 @@ function drawTrendChart(detail) {
setChartHover(hoverPoints, tempSymbol);
}
function renderDecision(detail) {
if (!els.decisionDirection || !els.decisionConfidence || !els.decisionSummary || !els.decisionReasons) {
return;
}
const symbol = detail?.temp_symbol || "°C";
const currentTemp = Number(detail?.current?.temp);
const maxSoFar = Number(detail?.current?.max_temp_so_far);
const trendRows = extractTrendSeries(detail).trend;
const obsRows = getObservationRows(detail);
const obsLatest = obsRows.length ? obsRows[obsRows.length - 1] : null;
const currentMinute = Number.isFinite(obsLatest?.minute)
? obsLatest.minute
: parseTimeToMinute(detail?.current?.obs_time);
const futureRows = trendRows.filter((row) => Number.isFinite(row.m) && Number.isFinite(currentMinute) ? row.m >= currentMinute : true);
const shortWindow = futureRows.slice(0, 4);
const hours = Math.max(1, Math.min(4, shortWindow.length || 4));
const baseTemp = Number.isFinite(currentTemp)
? currentTemp
: (obsLatest && Number.isFinite(obsLatest.temp) ? obsLatest.temp : NaN);
let directionKey = "directionFlat";
let summaryKey = "decisionSummaryFlat";
let confidenceKey = "confidenceNeutral";
let confidenceClass = "neutral";
const reasons = [];
const futureTemps = shortWindow.map((row) => Number(row.v)).filter(Number.isFinite);
const futureMax = futureTemps.length ? Math.max(...futureTemps) : Number.NaN;
const futureMin = futureTemps.length ? Math.min(...futureTemps) : Number.NaN;
const futureEnd = futureTemps.length ? futureTemps[futureTemps.length - 1] : Number.NaN;
const projectedDelta = Number.isFinite(baseTemp) && Number.isFinite(futureEnd)
? futureEnd - baseTemp
: Number.NaN;
const windowAmplitude = Number.isFinite(futureMax) && Number.isFinite(futureMin)
? futureMax - futureMin
: Number.NaN;
if (Number.isFinite(projectedDelta)) {
if (projectedDelta >= 0.8) {
directionKey = "directionWarmer";
summaryKey = "decisionSummaryWarmer";
confidenceKey = projectedDelta >= 1.8 ? "confidenceHigh" : projectedDelta >= 1.2 ? "confidenceMedium" : "confidenceLow";
confidenceClass = projectedDelta >= 1.8 ? "high" : projectedDelta >= 1.2 ? "medium" : "low";
} else if (projectedDelta <= -0.8) {
directionKey = "directionCooler";
summaryKey = "decisionSummaryCooler";
const absDelta = Math.abs(projectedDelta);
confidenceKey = absDelta >= 1.8 ? "confidenceHigh" : absDelta >= 1.2 ? "confidenceMedium" : "confidenceLow";
confidenceClass = absDelta >= 1.8 ? "high" : absDelta >= 1.2 ? "medium" : "low";
} else {
confidenceKey = Math.abs(projectedDelta) >= 0.4 ? "confidenceLow" : "confidenceNeutral";
confidenceClass = Math.abs(projectedDelta) >= 0.4 ? "low" : "neutral";
}
}
if (Number.isFinite(baseTemp) && Number.isFinite(futureMax)) {
const upside = futureMax - baseTemp;
const downside = baseTemp - futureMin;
if (directionKey === "directionWarmer" && upside > 0.3) {
reasons.push(tf("reasonDebAbove", { delta: upside.toFixed(1), symbol }));
} else if (directionKey === "directionCooler" && downside > 0.3) {
reasons.push(tf("reasonDebBelow", { delta: downside.toFixed(1), symbol }));
}
}
if (Number.isFinite(baseTemp) && trendRows.length) {
const nearest = trendRows.reduce((best, row) => {
if (!Number.isFinite(row.m) || !Number.isFinite(currentMinute)) return best;
if (!best) return row;
return Math.abs(row.m - currentMinute) < Math.abs(best.m - currentMinute) ? row : best;
}, null);
if (nearest && Number.isFinite(nearest.v)) {
const omGap = baseTemp - nearest.v;
if (omGap >= 0.8) {
reasons.push(tf("reasonObsAboveOm", { delta: omGap.toFixed(1), symbol }));
} else if (omGap <= -0.8) {
reasons.push(tf("reasonObsBelowOm", { delta: Math.abs(omGap).toFixed(1), symbol }));
}
}
}
if (Number.isFinite(baseTemp) && Number.isFinite(maxSoFar)) {
const gapToPeak = maxSoFar - baseTemp;
if (gapToPeak <= 0.6) {
reasons.push(t("reasonNearPeak"));
} else if (gapToPeak >= 1.2) {
reasons.push(tf("reasonRoomToPeak", { delta: gapToPeak.toFixed(1), symbol }));
}
}
if (Number.isFinite(windowAmplitude)) {
if (windowAmplitude >= 2.2) reasons.push(t("reasonRangeWide"));
else if (windowAmplitude <= 1.0) reasons.push(t("reasonRangeTight"));
}
if (!obsRows.length) {
reasons.push(t("reasonNoObs"));
}
els.decisionDirection.textContent = t(directionKey);
els.decisionWindow.textContent = tf("decisionWindow", { hours });
els.decisionConfidence.textContent = t(confidenceKey);
els.decisionConfidence.classList.remove("high", "medium", "low", "neutral");
els.decisionConfidence.classList.add(confidenceClass);
els.decisionSummary.textContent = t(summaryKey);
els.decisionReasons.innerHTML = "";
for (const reason of reasons.slice(0, 3)) {
const li = document.createElement("li");
li.textContent = reason;
els.decisionReasons.appendChild(li);
}
}
function renderForecast(detail) {
const symbol = detail?.temp_symbol || "°C";
const daily = Array.isArray(detail?.forecast?.daily) ? detail.forecast.daily : [];
const dailyDeb = detail?.multi_model_daily || {};
els.forecastRow.innerHTML = "";
for (let i = 0; i < Math.min(daily.length, 6); i += 1) {
const day = daily[i];
const debValue = dailyDeb?.[day?.date]?.deb?.prediction;
const displayTemp = debValue ?? day?.max_temp;
const card = document.createElement("div");
card.className = `forecast-card ${i === 0 ? "today" : ""}`;
@@ -775,7 +986,7 @@ function renderForecast(detail) {
const v = document.createElement("div");
v.className = "f-temp";
v.textContent = formatTemp(day?.max_temp, symbol);
v.textContent = formatTemp(displayTemp, symbol);
card.appendChild(v);
els.forecastRow.appendChild(card);
@@ -789,6 +1000,7 @@ function renderDetail(detail) {
state.detail = detail;
renderRiskBadge(detail);
renderFreshness(detail);
const tempSymbol = detail?.temp_symbol || "°C";
const profile = getSettlementSourceDisplay(detail);
els.settlementLabel.textContent = profile.label;
@@ -803,6 +1015,7 @@ function renderDetail(detail) {
: `${nearby}${t("nearbyMonitoringSuffix")}`;
drawTrendChart(detail);
renderDecision(detail);
renderForecast(detail);
const sourceCode = String(detail?.current?.settlement_source || "").toLowerCase();
@@ -815,8 +1028,8 @@ function renderDetail(detail) {
const last = obs[obs.length - 1];
els.chartLegend.textContent =
sourceCode === "noaa"
? `${sourceTag}: ${first.temp}°C@${first.time} -> ${last.temp}°C@${last.time} | ${t("noaaSettlementLegend")}`
: `${sourceTag}: ${first.temp}°C@${first.time} -> ${last.temp}°C@${last.time}`;
? `${sourceTag}: ${first.temp}${tempSymbol}@${first.time} -> ${last.temp}${tempSymbol}@${last.time} | ${t("noaaSettlementLegend")}`
: `${sourceTag}: ${first.temp}${tempSymbol}@${first.time} -> ${last.temp}${tempSymbol}@${last.time}`;
} else {
els.chartLegend.textContent =
sourceCode === "noaa"
@@ -858,6 +1071,10 @@ function normalizeAggregateDetail(payload) {
forecast: {
daily: Array.isArray(timeseries.forecast_daily) ? timeseries.forecast_daily : []
},
multi_model_daily:
timeseries.multi_model_daily && typeof timeseries.multi_model_daily === "object"
? timeseries.multi_model_daily
: {},
hourly: timeseries.hourly || { times: [], temps: [] },
metar_today_obs: timeseries.metar_today_obs || [],
settlement_today_obs: timeseries.settlement_today_obs || [],
@@ -874,8 +1091,10 @@ function applyStaticTranslations() {
if (els.obsTimeLabel) els.obsTimeLabel.textContent = t("obsUpdate");
if (els.nearbyLabel) els.nearbyLabel.textContent = t("nearbyStations");
if (els.trendTitle) els.trendTitle.textContent = t("intradayTrend");
if (els.decisionTitle) els.decisionTitle.textContent = t("directionTitle");
if (els.forecastTitle) els.forecastTitle.textContent = t("forecast");
if (els.openFullBtn) els.openFullBtn.textContent = t("openFull");
if (els.openFullHint) els.openFullHint.textContent = t("openFullHint");
if (els.refreshBtn) {
els.refreshBtn.title = t("refresh");
els.refreshBtn.setAttribute("aria-label", t("refresh"));
@@ -974,11 +1193,14 @@ async function loadCities(options = {}) {
}
}
function getActiveTabUrl() {
function getActiveTabInfo() {
return new Promise((resolve) => {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const first = Array.isArray(tabs) && tabs.length ? tabs[0] : null;
resolve(String(first?.url || ""));
resolve({
url: String(first?.url || ""),
title: String(first?.title || "")
});
});
});
}
@@ -987,12 +1209,12 @@ async function syncCityFromActiveUrl() {
if (state.syncBusy || !state.cities.length) return;
state.syncBusy = true;
try {
const url = await getActiveTabUrl();
const { url, title } = await getActiveTabInfo();
if (!url) return;
if (url === state.lastActiveUrl) return;
state.lastActiveUrl = url;
const inferred = inferCityFromUrl(url);
const inferred = inferCityFromUrl(url) || matchCityInText(title);
if (!inferred) return;
if (inferred === state.config.selectedCity) return;
await setSelectedCity(inferred, { persist: true, reloadDetail: true });
@@ -1025,10 +1247,8 @@ function bindUrlSync() {
}
function openMainSite(view) {
const city = encodeURIComponent(state.config.selectedCity || "");
const siteBase = normalizeBase(state.config.siteBase || state.config.apiBase);
const url = `${siteBase}/?city=${city}&view=${encodeURIComponent(view || "dashboard")}`;
chrome.tabs.create({ url });
chrome.tabs.create({ url: siteBase });
}
function bindEvents() {
+42 -6
View File
@@ -19,12 +19,31 @@ PolyWeather Pro 的生产前端工程。
2. Next Route Handlers`/api/*`-> FastAPI 后端
3. FastAPI -> 分析服务 / 支付服务
## 当前前端能力
- 主站 Dashboard 支持地图、城市详情、今日日内分析、历史准确率对账和账户中心
- `/docs` 已提供公开双语产品文档中心,解释日内结构信号、TAF、结算来源和历史对账
- 今日日内分析支持:
- `锚点状态`
- `当前节奏`
- `当前命中胜率`
- `模型区间与分歧`
- `今日日内结构信号`
- 非香港机场城市的 `TAF` 时段提示与走势图联动
- 历史对账支持:
- `DEB / 最佳单模型 / 实测最高温` 对比
- 峰值前 12 小时 `DEB` 参考(近似)
- `/ops` 已支持桌面表格 + 手机端卡片化视图
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
- `/ops` 现已展示 prewarm worker 运行态、缓存桶状态与 summary cache hit/miss
## 本地开发
```bash
cd frontend
cp .env.example .env.local
npm install
npm ci
npm run dev
```
@@ -67,10 +86,16 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
# 社群入口
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
# 推荐默认关闭的前端观测 / 预热开关
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
NEXT_PUBLIC_POLYWEATHER_WEB_VITALS=false
NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
```
更完整的 Vercel 配置说明见:
@@ -121,15 +146,19 @@ Ops
- 系统状态
- SQLite / rollout / 支付运行态
- prewarm worker 运行态
- 缓存桶状态与 summary cache hit/miss
- 用户查询
- 当前会员
- 本周积分榜
- 手动补分
- 支付异常单筛选与标记已处理
- 手机端卡片化视图
注意:
- `/ops` 页面是否可写,取决于后端 `POLYWEATHER_OPS_ADMIN_EMAILS`
- `/ops` 现在是前后端双层管理员限制
- Vercel 前端和后端都应配置相同的 `POLYWEATHER_OPS_ADMIN_EMAILS`
- 前端登录邮箱本身不会自动获得管理员权限
## 支付安全补充
@@ -152,12 +181,19 @@ Ops
- `cities` / `summary` / `history``ETag + Cache-Control`
- `summary?force_refresh=true``no-store`
- 支付相关路由:`no-store`
- 当 detail 缓存只返回单模型或单日 forecast 时,前端会自动强刷完整 detail
## 开源边界说明
## Vercel 节流建议
此前端仓库包含通用产品界面和标准支付体验。
商业策略调优、私有运营流程和敏感生产参数不在公开文档范围内。
- 生产环境建议关闭 `Web Analytics``Speed Insights`
- 建议把自建 `app analytics / web vitals / eager city summaries` 默认保持关闭
- 如果你部署在 Vercel,可在 Firewall 中加一条 `WordPress / php scanner` 拦截规则,避免无效扫描白白触发 middleware
## AGPL 与商用边界说明
此前端代码随仓库一起采用 `AGPL-3.0-only`
生产私有运营流程、商业策略调优、敏感生产参数、品牌与托管服务能力不在代码许可证授权范围内。
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
最后更新:`2026-03-21`
最后更新:`2026-04-10`
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const ANALYTICS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true";
export async function POST(req: NextRequest) {
if (!ANALYTICS_ENABLED) {
return new NextResponse(null, { status: 204 });
}
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 260) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to track analytics event", detail: String(error) },
{ status: 500 },
);
}
}
+56 -4
View File
@@ -16,12 +16,45 @@ export async function GET(req: NextRequest) {
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/auth/me`, {
headers: auth.headers,
cache: "no-store",
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 6000);
let res: Response;
try {
res = await fetch(`${API_BASE}/api/auth/me`, {
headers: auth.headers,
cache: "no-store",
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
if (res.status === 401 || res.status === 403) {
const response = NextResponse.json({
authenticated: false,
subscription_active: false,
points: 0,
});
return applyAuthResponseCookies(response, auth.response);
}
if (!res.ok) {
const raw = await res.text();
if (auth.authUserId) {
const response = NextResponse.json({
authenticated: true,
user_id: auth.authUserId,
email: auth.authEmail || null,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: `backend_${res.status}`,
});
return applyAuthResponseCookies(response, auth.response);
}
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: res.status },
@@ -32,9 +65,28 @@ export async function GET(req: NextRequest) {
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const auth = await buildBackendRequestHeaders(req);
if (auth.authUserId) {
const response = NextResponse.json({
authenticated: true,
user_id: auth.authUserId,
email: auth.authEmail || null,
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: 0,
degraded_auth_profile: true,
degraded_reason: String(error),
});
return applyAuthResponseCookies(response, auth.response);
}
return NextResponse.json(
{ error: "Failed to fetch auth profile", detail: String(error) },
{ status: 500 },
);
}
}
+4 -2
View File
@@ -18,7 +18,9 @@ export async function GET(req: NextRequest) {
}
try {
const auth = await buildBackendRequestHeaders(req);
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const res = await fetch(`${API_BASE}/api/cities`, {
headers: auth.headers,
cache: "no-store",
@@ -35,7 +37,7 @@ export async function GET(req: NextRequest) {
const response = buildCachedJsonResponse(
req,
data,
"public, max-age=0, s-maxage=300, stale-while-revalidate=1800",
"no-store, max-age=0",
);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
+5 -2
View File
@@ -20,10 +20,13 @@ export async function GET(
const { name } = await context.params;
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`;
const depth = req.nextUrl.searchParams.get("depth") ?? "panel";
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}&depth=${encodeURIComponent(depth)}`;
try {
const auth = await buildBackendRequestHeaders(req);
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const res = await fetch(url, {
headers: auth.headers,
cache: "no-store",
+14 -3
View File
@@ -25,10 +25,21 @@ export async function GET(
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
try {
const auth = await buildBackendRequestHeaders(req);
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const fetchOptions =
bypassCache
? {
headers: auth.headers,
cache: "no-store" as const,
}
: {
headers: auth.headers,
next: { revalidate: 20 },
};
const res = await fetch(url, {
headers: auth.headers,
cache: "no-store",
...fetchOptions,
});
if (!res.ok) {
const raw = await res.text();
+3 -1
View File
@@ -15,7 +15,9 @@ export async function GET(req: NextRequest) {
}
try {
const auth = await buildBackendRequestHeaders(req);
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const res = await fetch(`${API_BASE}/healthz`, {
headers: auth.headers,
cache: "no-store",
+5 -2
View File
@@ -24,9 +24,12 @@ export async function GET(
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(url, {
const fetchOptions = {
headers: auth.headers,
cache: "no-store",
next: { revalidate: 60 },
} as const;
const res = await fetch(url, {
...fetchOptions,
});
if (!res.ok) {
const raw = await res.text();
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const url = new URL(`${API_BASE}/api/ops/analytics/funnel`);
const days = req.nextUrl.searchParams.get("days");
if (days) {
url.searchParams.set("days", days);
}
const res = await fetch(url.toString(), {
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch analytics funnel", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const url = new URL(`${API_BASE}/api/ops/truth-history`);
for (const key of ["city", "date_from", "date_to", "limit"]) {
const value = req.nextUrl.searchParams.get(key);
if (value) url.searchParams.set(key, value);
}
const res = await fetch(url.toString(), {
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch truth history", detail: String(error) },
{ status: 500 },
);
}
}
+17
View File
@@ -5,6 +5,9 @@ import {
recordVitalsSample,
} from "@/lib/vitals-store";
const WEB_VITALS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_WEB_VITALS === "true";
type VitalsPayload = {
id?: string;
metric?: string;
@@ -15,6 +18,10 @@ type VitalsPayload = {
};
export async function POST(request: Request) {
if (!WEB_VITALS_ENABLED) {
return new NextResponse(null, { status: 204 });
}
try {
const payload = (await request.json()) as VitalsPayload;
const metric = normalizeMetricName(payload.metric);
@@ -56,6 +63,16 @@ export async function POST(request: Request) {
}
export async function GET(request: Request) {
if (!WEB_VITALS_ENABLED) {
return NextResponse.json({
ok: true,
disabled: true,
generatedAt: Date.now(),
sampleCount: 0,
routes: {},
});
}
const { searchParams } = new URL(request.url);
const targetRoute = String(searchParams.get("route") || "").trim();
const summary = getVitalsSummary();
+27
View File
@@ -0,0 +1,27 @@
import { notFound } from "next/navigation";
import { DocsScreen } from "@/components/docs/DocsScreen";
import { DOCS_PAGES, getDocsPage } from "@/content/docs/docs";
export function generateStaticParams() {
return DOCS_PAGES.map((page) => ({ slug: [page.slug] }));
}
export default async function DocsDetailPage({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const resolvedParams = await params;
if ((resolvedParams.slug?.length || 0) > 1) {
notFound();
}
const slug = resolvedParams.slug?.[0] || "intro";
const page = getDocsPage(slug);
if (!page) {
notFound();
}
return <DocsScreen page={page} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { I18nProvider } from "@/hooks/useI18n";
export default function DocsLayout({ children }: { children: React.ReactNode }) {
return <I18nProvider>{children}</I18nProvider>;
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function DocsIndexPage() {
redirect("/docs/intro");
}
+169 -50
View File
@@ -2,13 +2,101 @@
@tailwind components;
@tailwind utilities;
/*
PolyWeather Unified Design Token System
*/
@layer base {
:root {
/* ── Background Scale ── */
--color-bg-base: #080c14;
--color-bg-raised: #0d1321;
--color-bg-overlay: #111827;
--color-bg-card: rgba(15, 23, 42, 0.72);
--color-bg-input: rgba(255, 255, 255, 0.04);
/* ── Text Scale ── */
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-text-disabled: #475569;
/* ── Accent Colors ── */
--color-accent-primary: #00e5ff;
--color-accent-secondary: #6366f1;
--color-accent-tertiary: #a78bfa;
/* ── Signal / Semantic Colors ── */
--color-signal-success: #22c55e;
--color-signal-warning: #f59e0b;
--color-signal-danger: #ef4444;
--color-signal-info: #3b82f6;
/* ── Risk Colors (aliased from signal) ── */
--color-risk-high: var(--color-signal-danger);
--color-risk-medium: var(--color-signal-warning);
--color-risk-low: var(--color-signal-success);
/* ── Border ── */
--color-border-default: rgba(99, 102, 241, 0.12);
--color-border-hover: rgba(99, 102, 241, 0.28);
--color-border-subtle: rgba(255, 255, 255, 0.06);
/* ── Shadow / Elevation ── */
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-elevation-2: 0 8px 24px rgba(0, 0, 0, 0.45);
--shadow-elevation-3: 0 20px 60px rgba(0, 0, 0, 0.6);
--shadow-glow-accent: 0 0 20px rgba(0, 229, 255, 0.2);
--shadow-glow-secondary: 0 0 20px rgba(99, 102, 241, 0.25);
/* ── Typography ── */
--font-data:
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-display: "Geist", "Inter", -apple-system, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
/* ── Spacing (4px grid) ── */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
/* ── Border Radius ── */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
--radius-full: 9999px;
/* ── Glass / Blur ── */
--glass-blur-1: blur(10px);
--glass-blur-2: blur(16px);
--glass-blur-3: blur(24px);
--glass-opacity-1: 0.72;
--glass-opacity-2: 0.85;
--glass-opacity-3: 0.92;
/* ── Layout ── */
--header-height: 52px;
--sidebar-width: 260px;
--panel-width: 560px;
/* ── Motion ── */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.16, 1, 0.3, 1);
/* ── Legacy Compatibility (shadcn/ui HSL tokens) ── */
--background: 223 53% 4%;
--foreground: 210 40% 98%;
--card: 223 46% 8%;
--card-foreground: 210 40% 98%;
--primary: 190 95% 56%;
--primary: 187 100% 50%;
--primary-foreground: 222 47% 8%;
--secondary: 224 30% 14%;
--secondary-foreground: 210 40% 98%;
@@ -22,28 +110,32 @@
}
body {
font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif;
font-family: var(--font-data);
background:
radial-gradient(
circle at 10% -10%,
rgba(34, 211, 238, 0.2),
rgba(0, 229, 255, 0.12),
transparent 40%
),
radial-gradient(
circle at 90% 0%,
rgba(59, 130, 246, 0.14),
rgba(99, 102, 241, 0.1),
transparent 36%
),
radial-gradient(
circle at 80% 100%,
rgba(8, 47, 73, 0.5),
rgba(8, 47, 73, 0.4),
transparent 48%
),
hsl(var(--background));
color: hsl(var(--foreground));
var(--color-bg-base);
color: var(--color-text-primary);
}
}
/*
Map Components (Leaflet overrides + map markers)
*/
@layer components {
.leaflet-container {
width: 100%;
@@ -52,38 +144,41 @@
}
.leaflet-control-zoom a {
background: rgba(2, 6, 23, 0.82) !important;
border-color: rgba(71, 85, 105, 0.5) !important;
color: #e2e8f0 !important;
background: rgba(8, 12, 20, 0.85) !important;
border-color: var(--color-border-default) !important;
color: var(--color-text-primary) !important;
}
.leaflet-control-zoom a:hover {
background: rgba(15, 23, 42, 0.95) !important;
background: rgba(13, 19, 33, 0.95) !important;
border-color: var(--color-border-hover) !important;
}
/* ── Map Pill (temperature labels on map) ── */
.map-pill {
min-width: 52px;
border-radius: 9999px;
border-radius: var(--radius-full);
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 5px 10px;
color: #f8fafc;
color: var(--color-text-primary);
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: 0.04em;
text-transform: uppercase;
backdrop-filter: blur(8px);
backdrop-filter: var(--glass-blur-1);
box-shadow:
0 2px 14px rgba(2, 6, 23, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.map-pill.high {
background: linear-gradient(135deg, #ef4444, #b91c1c);
background: linear-gradient(135deg, var(--color-signal-danger), #b91c1c);
}
.map-pill.medium {
background: linear-gradient(135deg, #f59e0b, #b45309);
background: linear-gradient(135deg, var(--color-signal-warning), #b45309);
}
.map-pill.low {
@@ -93,21 +188,23 @@
.map-pill.active {
transform: translateY(-2px) scale(1.05);
box-shadow:
0 8px 24px rgba(34, 211, 238, 0.4),
0 8px 24px rgba(0, 229, 255, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
/* ── Glass utility ── */
.glass {
background: linear-gradient(
180deg,
rgba(15, 23, 42, 0.9) 0%,
rgba(2, 6, 23, 0.75) 100%
rgba(13, 19, 33, 0.88) 0%,
rgba(8, 12, 20, 0.75) 100%
);
backdrop-filter: blur(10px);
backdrop-filter: var(--glass-blur-1);
}
/* ── Fade-up entrance ── */
.fade-up {
animation: fadeUp 450ms ease-out;
animation: fadeUp 400ms var(--transition-slow);
}
}
@@ -124,47 +221,52 @@
}
}
/*
Map Marker Components (nearby stations, city bubbles)
*/
@layer components {
.nearby-marker {
display: flex;
align-items: center;
gap: 6px;
background: rgba(15, 23, 42, 0.9);
color: rgba(226, 232, 240, 0.92);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 4px 10px;
gap: var(--space-2);
background: rgba(13, 19, 33, 0.9);
color: var(--color-text-secondary);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
padding: var(--space-1) var(--space-3);
font-size: 11px;
font-weight: 600;
box-shadow: 0 10px 28px rgba(2, 6, 23, 0.45);
backdrop-filter: blur(10px);
box-shadow: var(--shadow-elevation-2);
backdrop-filter: var(--glass-blur-1);
white-space: nowrap;
}
.nearby-name {
color: rgba(148, 163, 184, 0.95);
color: var(--color-text-secondary);
}
.nearby-temp {
color: #fff;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.nearby-unit {
color: rgba(148, 163, 184, 0.85);
color: var(--color-text-muted);
font-size: 9px;
}
.wind-info {
display: flex;
align-items: center;
gap: 4px;
margin-left: 4px;
padding-left: 6px;
border-left: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(34, 211, 238, 0.9);
gap: var(--space-1);
margin-left: var(--space-1);
padding-left: var(--space-2);
border-left: 1px solid var(--color-border-subtle);
color: var(--color-accent-primary);
font-size: 10px;
opacity: 0.9;
}
.wind-arrow {
@@ -172,6 +274,7 @@
transform-origin: center;
}
/* ── City Marker (bubble on map) ── */
.city-marker {
position: relative;
display: flex;
@@ -184,13 +287,14 @@
position: relative;
min-width: 46px;
padding: 5px 10px;
border-radius: 12px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 800;
font-variant-numeric: tabular-nums;
text-align: center;
color: white;
border: 1px solid transparent;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
box-shadow: var(--shadow-elevation-2);
}
.marker-bubble::after {
@@ -207,48 +311,63 @@
}
.marker-bubble.risk-high {
background: linear-gradient(135deg, #dc2626, #ef4444);
background: linear-gradient(135deg, #dc2626, var(--color-signal-danger));
border-color: rgba(239, 68, 68, 0.5);
}
.marker-bubble.risk-high::after { border-top-color: #ef4444; }
.marker-bubble.risk-high::after {
border-top-color: var(--color-signal-danger);
}
.marker-bubble.risk-medium {
background: linear-gradient(135deg, #d97706, #f59e0b);
background: linear-gradient(135deg, #d97706, var(--color-signal-warning));
border-color: rgba(245, 158, 11, 0.5);
}
.marker-bubble.risk-medium::after { border-top-color: #f59e0b; }
.marker-bubble.risk-medium::after {
border-top-color: var(--color-signal-warning);
}
.marker-bubble.risk-low {
background: linear-gradient(135deg, #059669, #10b981);
border-color: rgba(16, 185, 129, 0.5);
}
.marker-bubble.risk-low::after { border-top-color: #10b981; }
.marker-bubble.risk-low::after {
border-top-color: #10b981;
}
.marker-name {
margin-top: 8px;
margin-top: var(--space-2);
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.88);
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
.city-marker.selected .marker-bubble {
animation: markerGlow 2s ease-in-out infinite;
}
/* ── Scrollbar ── */
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(51, 65, 85, 0.75);
border-radius: 9999px;
background: rgba(99, 102, 241, 0.2);
border-radius: var(--radius-full);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.35);
}
}
@keyframes markerGlow {
0%, 100% { box-shadow: 0 6px 18px rgba(0,0,0,0.4); }
50% { box-shadow: 0 10px 26px rgba(34,211,238,0.35); }
0%,
100% {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
}
50% {
box-shadow: 0 10px 26px rgba(0, 229, 255, 0.3);
}
}
+10 -12
View File
@@ -1,7 +1,4 @@
import type { Metadata } from "next";
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { WebVitalsReporter } from "@/components/observability/WebVitalsReporter";
import "./globals.css";
export const metadata: Metadata = {
@@ -15,7 +12,9 @@ export const metadata: Metadata = {
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
],
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
apple: [
{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
],
shortcut: ["/favicon.ico"],
},
};
@@ -27,18 +26,17 @@ export default function RootLayout({
<html lang="zh-CN" className="dark">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin=""
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body className="min-h-screen font-sans antialiased">
{children}
<WebVitalsReporter />
<Analytics />
<SpeedInsights />
</body>
<body className="min-h-screen font-sans antialiased">{children}</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
export default function Loading() {
return <DashboardShellSkeleton />;
}
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
import { TruthHistoryDashboard } from "@/components/ops/TruthHistoryDashboard";
import { requireOpsAdmin } from "@/lib/ops-admin";
export const metadata: Metadata = {
title: "PolyWeather Truth History",
description: "Admin truth history viewer for PolyWeather.",
};
export default async function TruthHistoryPage() {
await requireOpsAdmin("/ops/truth-history");
return <TruthHistoryDashboard />;
}
+312 -40
View File
@@ -46,6 +46,7 @@ import {
getCurrentPaymentHost,
isPaymentHostAllowed,
} from "@/lib/payment-host";
import { trackAppEvent } from "@/lib/app-analytics";
import { useI18n } from "@/hooks/useI18n";
const UnlockProOverlay = dynamic(
@@ -75,6 +76,9 @@ type AuthMeResponse = {
subscription_plan_code?: string | null;
subscription_starts_at?: string | null;
subscription_expires_at?: string | null;
subscription_total_expires_at?: string | null;
subscription_queued_days?: number | null;
subscription_queued_count?: number | null;
};
type PaymentPlan = {
@@ -204,6 +208,13 @@ type ConnectBindOptions = {
openOverlayAfterBind?: boolean;
};
type PaymentRecoveryState = {
intentId: string;
txHash: string;
userId: string;
createdAt: number;
};
const WALLETCONNECT_PROJECT_ID = String(
process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "",
).trim();
@@ -218,7 +229,10 @@ const TELEGRAM_GROUP_URL = String(
const TELEGRAM_BOT_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/WeatherQuant_bot",
).trim();
const TELEGRAM_MARKET_CHANNEL_URL = "https://t.me/+hGAk7JsjtdhiOTUx";
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
const PAYMENT_RECOVERY_STORAGE_KEY = "polyweather:lastPaymentRecovery";
const PAYMENT_RECOVERY_TTL_MS = 6 * 60 * 60 * 1000;
let walletConnectProviderCache: EvmProvider | null = null;
let walletConnectProviderChainId: number | null = null;
@@ -301,12 +315,31 @@ function formatTime(value: string | undefined | null, locale: string) {
}
}
function parseSubscriptionExpiry(value: string | undefined | null) {
const raw = String(value || "").trim();
if (!raw) return null;
const dt = new Date(raw);
if (Number.isNaN(dt.getTime())) return null;
const diffMs = dt.getTime() - Date.now();
return {
raw,
date: dt,
expired: diffMs <= 0,
daysLeft: Math.ceil(diffMs / 86_400_000),
};
}
function shortAddress(address: string) {
const text = String(address || "");
if (!text.startsWith("0x") || text.length < 12) return text || "--";
return `${text.slice(0, 8)}...${text.slice(-6)}`;
}
function clearStoredPaymentRecovery() {
if (typeof window === "undefined") return;
window.sessionStorage.removeItem(PAYMENT_RECOVERY_STORAGE_KEY);
}
function getEvmProvider(): EvmProvider | null {
return listInjectedProviders()[0]?.provider || null;
}
@@ -388,11 +421,14 @@ function getInjectedProviderStableId(
detail?: Eip6963ProviderDetail,
): string {
const rdns = String(detail?.info?.rdns || "").toLowerCase();
const announcedName = String(detail?.info?.name || "").toLowerCase().trim();
const announcedName = String(detail?.info?.name || "")
.toLowerCase()
.trim();
if (rdns) return `rdns:${rdns}`;
if (announcedName) return `name:${announcedName}`;
if (provider.isOkxWallet || rdns.includes("okx")) return `okx:${index}`;
if (provider.isMetaMask || rdns.includes("metamask")) return `metamask:${index}`;
if (provider.isMetaMask || rdns.includes("metamask"))
return `metamask:${index}`;
if (provider.isRabby || rdns.includes("rabby")) return `rabby:${index}`;
if (
provider.isBitKeep ||
@@ -652,6 +688,7 @@ export function AccountCenter() {
guestUser: isEn ? "Guest User" : "游客用户",
joinedAt: isEn ? "Joined" : "加入时间",
totalPoints: isEn ? "Total Points" : "总积分 (荣誉)",
weeklyPoints: isEn ? "Weekly Points" : "本周积分 (竞技)",
weeklyRank: isEn ? "Weekly Rank" : "周排行 (竞技)",
weeklyRewards: isEn ? "Weekly Rewards" : "周榜奖励",
membershipDetails: isEn ? "Membership Details" : "会员权限详情",
@@ -674,6 +711,7 @@ export function AccountCenter() {
boundEmail: isEn ? "Bound Email" : "绑定邮箱",
loginMethod: isEn ? "Sign-in Method" : "登录方式",
renewalDate: isEn ? "Renewal Date" : "续费日期",
accessUntil: isEn ? "Access Until" : "可用至",
authResult: isEn ? "Auth Result" : "鉴权结果",
passed: isEn ? "Passed" : "通过",
restricted: isEn ? "Restricted" : "受限",
@@ -683,11 +721,14 @@ export function AccountCenter() {
: "将下方命令发送给polyweather机器人,实现全平台气象查询与权限同步。",
paymentManualSupport: isEn
? "If payment succeeds but Pro is still not activated, email yhrsc30@gmail.com. This project is currently maintained by one developer, so manual recovery may be needed in edge cases."
: "如果付款成功后 Pro 仍未开通,请发邮件到 yhrsc30@gmail.com。当前项目由我一人维护,极少数边缘情况可能需要人工补开。",
: "如果付款成功后 Pro 仍未开通,请发邮件到 yhrsc30@gmail.com。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!",
telegramBotLink: isEn
? "Open Bot (@WeatherQuant_bot)"
: "打开机器人 (@WeatherQuant_bot)",
telegramGroupLink: isEn ? "Join Telegram Group" : "加入 Telegram 群组",
telegramMarketChannelLink: isEn
? "Join Market Monitor Channel"
: "加入市场监控频道",
copyCommand: isEn ? "Copy command" : "复制命令",
paymentMgmt: isEn ? "Payment Management" : "支付管理",
paymentToken: isEn ? "Payment Token" : "支付币种",
@@ -720,8 +761,8 @@ export function AccountCenter() {
? "Pro entitlement recovered."
: "Pro 权限已恢复。",
walletRecoveryFailed: isEn
? "Paid detected but entitlement is still pending. Please refresh in a minute or contact support."
: "检测到支付,但订阅状态仍在同步中。请稍后刷新,或联系管理员处理。",
? "A recent on-chain payment is still syncing to your subscription. Please refresh in a minute or contact support."
: "检测到最近的链上支付流程,但订阅状态仍在同步中。请稍后刷新,或联系管理员处理。",
unbind: isEn ? "Unbind" : "解绑",
unbindConfirm: isEn
? "Unbind wallet {address}? You can bind it again later."
@@ -764,6 +805,28 @@ export function AccountCenter() {
freeTier: "FREE TIER",
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
trialEndsSoonTitle: isEn ? "Trial ending soon" : "试用即将结束",
trialEndsSoonBody: isEn
? "Your 3-day trial is almost over. Upgrade to Pro to keep full intraday analysis and history."
: "你的 3 天试用即将结束。升级 Pro 后可继续使用完整日内分析和历史对账。",
trialExpiredTitle: isEn ? "Trial ended" : "试用已结束",
trialExpiredBody: isEn
? "Your trial access has ended. Renew with Pro to restore full access."
: "试用权限已结束。开通 Pro 后可恢复完整权限。",
proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期",
proEndsSoonBody: isEn
? "Your Pro membership will expire soon. Renew now to avoid interruption."
: "你的 Pro 会员即将到期。现在续费可避免权限中断。",
proExpiredTitle: isEn ? "Pro expired" : "Pro 已到期",
proExpiredBody: isEn
? "Your Pro membership has expired. Renew now to restore premium access."
: "你的 Pro 会员已到期。立即续费可恢复高级权限。",
renewNow: isEn ? "Renew Now" : "立即续费",
trialBadge: isEn ? "TRIAL" : "试用中",
daysLeft: isEn ? "{days} days left" : "剩余 {days} 天",
queuedExtensionSummary: isEn
? "Current plan until {current}. Queued extension: +{days} days. Total access until {total}."
: "当前订阅至 {current},已排队延长 +{days} 天,总可用至 {total}。",
}),
[isEn],
);
@@ -796,6 +859,7 @@ export function AccountCenter() {
const [paymentError, setPaymentError] = useState("");
const [lastIntentId, setLastIntentId] = useState("");
const [lastTxHash, setLastTxHash] = useState("");
const [lastPaymentStartedAt, setLastPaymentStartedAt] = useState(0);
const [showSecondarySections, setShowSecondarySections] = useState(false);
const [reconcileBusy, setReconcileBusy] = useState(false);
@@ -806,6 +870,9 @@ export function AccountCenter() {
const paymentReadyForRecovery = Boolean(
paymentConfig?.enabled && paymentConfig?.configured,
);
const hasRecentPaymentRecovery =
Boolean(lastIntentId && lastTxHash && authUserId && lastPaymentStartedAt) &&
Date.now() - lastPaymentStartedAt <= PAYMENT_RECOVERY_TTL_MS;
const allowedPaymentHosts = useMemo(() => getAllowedPaymentHosts(), []);
const currentPaymentHost = useMemo(() => getCurrentPaymentHost(), []);
const paymentHostAllowed = useMemo(
@@ -1241,22 +1308,61 @@ export function AccountCenter() {
useEffect(() => {
if (typeof window === "undefined") return;
if (!lastIntentId) return;
if (!(lastIntentId && lastTxHash && authUserId && lastPaymentStartedAt)) {
clearStoredPaymentRecovery();
return;
}
const payload: PaymentRecoveryState = {
intentId: lastIntentId,
txHash: lastTxHash,
userId: authUserId,
createdAt: lastPaymentStartedAt,
};
window.sessionStorage.setItem(
"polyweather:lastPaymentIntentId",
lastIntentId,
PAYMENT_RECOVERY_STORAGE_KEY,
JSON.stringify(payload),
);
}, [lastIntentId]);
}, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]);
useEffect(() => {
if (typeof window === "undefined") return;
const storedIntentId = window.sessionStorage.getItem(
"polyweather:lastPaymentIntentId",
);
if (storedIntentId && !lastIntentId) {
setLastIntentId(storedIntentId);
if (!authUserId) return;
if (lastIntentId && lastTxHash && lastPaymentStartedAt) return;
const raw = window.sessionStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY);
if (!raw) return;
try {
const parsed = JSON.parse(raw) as PaymentRecoveryState;
const userId = String(parsed?.userId || "").trim();
const intentId = String(parsed?.intentId || "").trim();
const txHash = String(parsed?.txHash || "").trim().toLowerCase();
const createdAt = Number(parsed?.createdAt || 0);
const expired =
!createdAt || Date.now() - createdAt > PAYMENT_RECOVERY_TTL_MS;
if (
expired ||
!intentId ||
!txHash ||
!userId ||
userId !== authUserId
) {
clearStoredPaymentRecovery();
return;
}
setLastIntentId(intentId);
setLastTxHash(txHash);
setLastPaymentStartedAt(createdAt);
} catch {
clearStoredPaymentRecovery();
}
}, [lastIntentId]);
}, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]);
useEffect(() => {
if (!backend?.subscription_active) return;
setLastIntentId("");
setLastTxHash("");
setLastPaymentStartedAt(0);
clearStoredPaymentRecovery();
}, [backend?.subscription_active]);
const onRefresh = async () => {
setRefreshing(true);
@@ -1308,7 +1414,7 @@ export function AccountCenter() {
if (!authIsAuthenticated) return;
if (backend?.subscription_active) return;
if (!paymentReadyForRecovery) return;
if (!lastIntentId) return;
if (!hasRecentPaymentRecovery) return;
let cancelled = false;
const run = async () => {
setPaymentInfo(copy.walletRecoveryBusy);
@@ -1328,12 +1434,16 @@ export function AccountCenter() {
authIsAuthenticated,
copy.walletRecoveryBusy,
copy.walletRecoveryFailed,
lastIntentId,
hasRecentPaymentRecovery,
paymentReadyForRecovery,
reconcileLatestPayment,
]);
const onSignOut = async () => {
setLastIntentId("");
setLastTxHash("");
setLastPaymentStartedAt(0);
clearStoredPaymentRecovery();
if (walletConnectProviderCache?.disconnect) {
try {
await walletConnectProviderCache.disconnect();
@@ -1364,15 +1474,88 @@ export function AccountCenter() {
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
const joinedAt = formatTime(user?.created_at, locale);
const isSubscribed = Boolean(backend?.subscription_active);
const expiryRaw = String(
const planCode = String(backend?.subscription_plan_code || "").trim();
const isTrialPlan = /trial/i.test(planCode);
const currentExpiryRaw = String(
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
).trim();
const expiryFormatted = formatTime(expiryRaw, locale);
const totalExpiryRaw = String(
backend?.subscription_total_expires_at ||
backend?.subscription_expires_at ||
user?.user_metadata?.pro_expiry ||
"",
).trim();
const queuedExtensionDays = Math.max(
0,
Number(backend?.subscription_queued_days || 0),
);
const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0);
const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw;
const reminderExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw || totalExpiryRaw;
const expiryInfo = parseSubscriptionExpiry(reminderExpiryRaw);
const expiryFormatted = formatTime(displayExpiryRaw, locale);
const currentExpiryFormatted = formatTime(currentExpiryRaw, locale);
const totalExpiryFormatted = formatTime(totalExpiryRaw, locale);
const proExpiry = isSubscribed
? expiryFormatted !== "--"
? expiryFormatted
: expiryRaw || copy.proPendingSync
: displayExpiryRaw || copy.proPendingSync
: copy.noProSubscription;
const showExpiringSoon =
Boolean(isSubscribed && expiryInfo && !expiryInfo.expired && expiryInfo.daysLeft <= 3);
const showExpiredReminder = Boolean(!isSubscribed && expiryInfo && expiryInfo.expired);
const paymentFeatureReady = paymentReadyForRecovery;
const canOpenCheckoutOverlay = Boolean(
paymentFeatureReady &&
(!isSubscribed || showExpiringSoon || showExpiredReminder),
);
const subscriptionStatusTitle = showExpiredReminder
? isTrialPlan
? copy.trialExpiredTitle
: copy.proExpiredTitle
: showExpiringSoon
? isTrialPlan
? copy.trialEndsSoonTitle
: copy.proEndsSoonTitle
: "";
const subscriptionStatusBody = showExpiredReminder
? isTrialPlan
? copy.trialExpiredBody
: copy.proExpiredBody
: showExpiringSoon
? isTrialPlan
? copy.trialEndsSoonBody
: copy.proEndsSoonBody
: "";
const subscriptionStatusMeta =
expiryInfo && (showExpiringSoon || showExpiredReminder)
? `${formatTime(expiryInfo.raw, locale)} · ${copy.daysLeft.replace("{days}", String(Math.max(expiryInfo.daysLeft, 0)))}`
: "";
const queuedExtensionSummary = hasQueuedExtension
? copy.queuedExtensionSummary
.replace("{current}", currentExpiryFormatted)
.replace("{days}", String(queuedExtensionDays))
.replace("{total}", totalExpiryFormatted)
: "";
const expiryLabel = hasQueuedExtension ? copy.accessUntil : copy.renewalDate;
useEffect(() => {
if (!showOverlay || !canOpenCheckoutOverlay) return;
trackAppEvent("paywall_viewed", {
entry: "account_center",
user_state: isAuthenticated ? "logged_in" : "guest",
expired: showExpiredReminder,
expiring_soon: showExpiringSoon,
subscription_plan_code: planCode || null,
});
}, [
isAuthenticated,
canOpenCheckoutOverlay,
planCode,
showExpiredReminder,
showExpiringSoon,
showOverlay,
]);
// Points Logic
const backendPointsRaw = Number(backend?.points);
@@ -1466,9 +1649,6 @@ export function AccountCenter() {
(resolvedSelectedTokenAddress.startsWith("0x")
? shortAddress(resolvedSelectedTokenAddress)
: "USDC");
const paymentFeatureReady = Boolean(
paymentConfig?.enabled && paymentConfig?.configured,
);
const paymentReceiverAddress = String(
selectedPaymentToken?.receiver_contract ||
paymentConfig?.receiver_contract ||
@@ -1605,6 +1785,12 @@ export function AccountCenter() {
if (status === "confirmed") {
setPaymentError("");
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHash)}`);
trackAppEvent("checkout_succeeded", {
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
tx_hash: txHash || null,
});
await loadSnapshot();
await loadPaymentSnapshot();
return;
@@ -1623,7 +1809,7 @@ export function AccountCenter() {
}
throw new Error("payment pending timeout");
},
[loadPaymentSnapshot, loadSnapshot],
[loadPaymentSnapshot, loadSnapshot, selectedPlan?.plan_code],
);
const signBindMessage = async (
@@ -1873,7 +2059,10 @@ export function AccountCenter() {
const createIntentAndPay = async () => {
setPaymentError("");
setPaymentInfo("");
setLastIntentId("");
setLastTxHash("");
setLastPaymentStartedAt(0);
clearStoredPaymentRecovery();
if (!paymentHostAllowed) {
setPaymentError(
copy.paymentHostBlocked.replace(
@@ -1956,7 +2145,8 @@ export function AccountCenter() {
}
if (
paymentConfig?.receiver_contract &&
String(paymentConfig.receiver_contract).toLowerCase() !== expectedReceiver
String(paymentConfig.receiver_contract).toLowerCase() !==
expectedReceiver
) {
setPaymentInfo(
`检测到支付配置已更新,已切换到最新地址 ${shortAddress(expectedReceiver)}`,
@@ -1996,6 +2186,13 @@ export function AccountCenter() {
const txPayload = created.tx_payload;
if (!intentId || !txPayload?.to || !txPayload?.data)
throw new Error("intent payload invalid");
trackAppEvent("checkout_started", {
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
use_points: billing.canRedeem && usePoints,
pay_amount_usd: billing.payAmount,
});
const intentReceiver = String(txPayload.to || "").toLowerCase();
if (intentReceiver !== expectedReceiver) {
throw new Error(
@@ -2085,6 +2282,7 @@ export function AccountCenter() {
})) as string;
const txHashNorm = String(txHash || "").toLowerCase();
setLastTxHash(txHashNorm);
setLastPaymentStartedAt(Date.now());
const submitRes = await fetch(
`/api/payments/intents/${intentId}/submit`,
@@ -2131,6 +2329,12 @@ export function AccountCenter() {
}
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`);
trackAppEvent("checkout_succeeded", {
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
tx_hash: txHashNorm,
});
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
@@ -2224,14 +2428,17 @@ export function AccountCenter() {
</div>
</div>
<div className="flex items-center gap-2">
{!isSubscribed && !showOverlay && paymentFeatureReady && (
<button
onClick={() => setShowOverlay(true)}
className="flex items-center gap-2 px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 text-yellow-500 rounded-xl text-sm transition-all animate-pulse"
>
<Crown size={16} /> {copy.upgradePro}
</button>
)}
{!showOverlay && canOpenCheckoutOverlay && (
<button
onClick={() => setShowOverlay(true)}
className="flex items-center gap-2 px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 text-yellow-500 rounded-xl text-sm transition-all animate-pulse"
>
<Crown size={16} />{" "}
{showExpiringSoon || showExpiredReminder
? copy.renewNow
: copy.upgradePro}
</button>
)}
<button
type="button"
onClick={() => void onRefresh()}
@@ -2264,6 +2471,41 @@ export function AccountCenter() {
</header>
<main className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-12 gap-6 z-10 relative">
{(showExpiringSoon || showExpiredReminder) && (
<div className="lg:col-span-12 rounded-[2rem] border border-amber-400/30 bg-amber-500/10 px-6 py-5 shadow-xl">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-bold text-amber-300">
<Crown size={16} />
<span>{subscriptionStatusTitle}</span>
</div>
<p className="mt-1 text-sm text-amber-50/90">
{subscriptionStatusBody}
</p>
{subscriptionStatusMeta ? (
<p className="mt-1 text-xs text-amber-200/80">
{subscriptionStatusMeta}
</p>
) : null}
{billing.canRedeem ? (
<p className="mt-2 text-xs text-emerald-200/90">
{billing.pointsUsed} ${billing.discountAmount.toFixed(2)}
</p>
) : null}
</div>
<button
type="button"
onClick={() => setShowOverlay(true)}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-amber-300/35 bg-amber-300/12 px-4 py-2 text-sm font-bold text-amber-100 transition-all hover:bg-amber-300/20"
>
<Crown size={16} />
{showExpiredReminder ? copy.renewNow : copy.upgradePro}
</button>
</div>
</div>
)}
{/* User Card */}
<div className="lg:col-span-8 bg-white/5 backdrop-blur-xl border border-white/10 rounded-[2.5rem] p-8 shadow-2xl flex flex-col md:flex-row items-center gap-8">
<div className="relative">
@@ -2282,7 +2524,11 @@ export function AccountCenter() {
<span
className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-tighter border ${isSubscribed ? "bg-blue-500/20 border-blue-500/40 text-blue-400" : "bg-slate-700/50 border-white/10 text-slate-500"}`}
>
{isSubscribed ? copy.proMember : copy.freeTier}
{isSubscribed
? isTrialPlan
? copy.trialBadge
: copy.proMember
: copy.freeTier}
</span>
</div>
<p className="text-slate-500 font-mono text-sm mb-4">
@@ -2313,12 +2559,22 @@ export function AccountCenter() {
{totalPoints.toLocaleString()}
</p>
</div>
<div className="px-6 py-4 bg-emerald-500/10 rounded-2xl border border-emerald-500/20 text-center min-w-[140px]">
<p className="text-[10px] text-emerald-300 uppercase tracking-widest mb-1 font-bold">
{copy.weeklyPoints}
</p>
<p className="text-xl font-bold text-white flex items-center justify-center gap-2">
<TrendingUp size={16} className="text-emerald-400" />{" "}
{weeklyPoints.toLocaleString()}
</p>
</div>
<div className="px-6 py-4 bg-blue-500/10 rounded-2xl border border-blue-500/20 text-center min-w-[140px]">
<p className="text-[10px] text-blue-400 uppercase tracking-widest mb-1 font-bold">
{copy.weeklyRank}
</p>
<p className="text-xl font-bold text-white flex items-center justify-center gap-2">
<Trophy size={16} className="text-amber-400" /> #{weeklyRank}
<Trophy size={16} className="text-amber-400" />{" "}
{weeklyRank === "--" ? weeklyRank : `#${weeklyRank}`}
</p>
</div>
</div>
@@ -2388,9 +2644,9 @@ export function AccountCenter() {
{/* Subscription Info & Paywall */}
<div className="lg:col-span-12 relative">
<div
className={`grid grid-cols-1 md:grid-cols-2 gap-6 transition-all duration-700 ${!isSubscribed && showOverlay ? "blur-md grayscale-[0.3] opacity-30 select-none pointer-events-none" : ""}`}
>
<div
className={`grid grid-cols-1 md:grid-cols-2 gap-6 transition-all duration-700 ${canOpenCheckoutOverlay && showOverlay ? "blur-md grayscale-[0.3] opacity-30 select-none pointer-events-none" : ""}`}
>
<section className="bg-white/5 border border-white/10 rounded-[2rem] p-6 space-y-3">
<h3 className="text-sm font-bold text-blue-400 uppercase tracking-widest mb-4">
{copy.membershipDetails}
@@ -2440,7 +2696,7 @@ export function AccountCenter() {
/>
<InfoRow
icon={Clock}
label={copy.renewalDate}
label={expiryLabel}
value={proExpiry}
isPrimary
/>
@@ -2449,11 +2705,16 @@ export function AccountCenter() {
label={copy.authResult}
value={backend?.authenticated ? copy.passed : copy.restricted}
/>
{queuedExtensionSummary ? (
<p className="rounded-2xl border border-cyan-400/20 bg-cyan-500/10 px-4 py-3 text-xs text-cyan-100">
{queuedExtensionSummary}
</p>
) : null}
</section>
</div>
{/* Paywall Mask */}
{!isSubscribed && showOverlay && (
{canOpenCheckoutOverlay && showOverlay && (
<div className="absolute inset-0 z-30 flex items-center justify-center p-4">
<UnlockProOverlay
points={totalPoints}
@@ -2512,6 +2773,17 @@ export function AccountCenter() {
<ExternalLink size={12} />
</Link>
) : null}
{TELEGRAM_MARKET_CHANNEL_URL ? (
<Link
href={TELEGRAM_MARKET_CHANNEL_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold text-emerald-200 hover:bg-emerald-500/20"
>
{copy.telegramMarketChannelLink}
<ExternalLink size={12} />
</Link>
) : null}
{TELEGRAM_GROUP_URL ? (
<Link
href={TELEGRAM_GROUP_URL}
+6
View File
@@ -71,6 +71,9 @@ export function LoginClient({ nextPath }: LoginClientProps) {
signupCheckEmail: isEn
? "Sign-up successful. Please verify your email before signing in."
: "注册成功,请检查邮箱并完成验证后登录。",
trialPromo: isEn
? "New users unlock a free 3-day Pro trial after sign-up."
: "新用户注册后可免费体验 3 天 Pro。",
} as const;
useEffect(() => {
@@ -190,6 +193,9 @@ export function LoginClient({ nextPath }: LoginClientProps) {
</div>
<h1 className="text-3xl font-bold tracking-tight text-white">PolyWeather</h1>
<p className="mt-2 text-sm text-slate-400">{copy.subtitle}</p>
<div className="mt-4 inline-flex items-center rounded-full border border-cyan-400/30 bg-cyan-400/10 px-4 py-1.5 text-xs font-semibold text-cyan-200 shadow-[0_0_20px_rgba(34,211,238,0.08)]">
{copy.trialPromo}
</div>
</div>
<button
+81 -16
View File
@@ -2,9 +2,10 @@
import { startTransition, useEffect, useMemo, useState } from "react";
import clsx from "clsx";
import { Clock } from "lucide-react";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { CityListItem } from "@/lib/dashboard-types";
import { CityListItem, DeviationMonitor } from "@/lib/dashboard-types";
type RiskGroupKey = "high" | "medium" | "low" | "other";
@@ -21,6 +22,10 @@ function toRiskGroup(level?: string): RiskGroupKey {
return "other";
}
function toPerformanceGroup(city: CityListItem): RiskGroupKey {
return toRiskGroup(city.deb_recent_tier);
}
function normalizeExpandedGroups(
value: unknown,
): Record<RiskGroupKey, boolean> {
@@ -50,7 +55,7 @@ function normalizeExpandedGroups(
export function CitySidebar() {
const store = useDashboardStore();
const { t } = useI18n();
const { locale, t } = useI18n();
const selectedCity = store.selectedCity;
const riskOrder = { high: 0, medium: 1, low: 2, other: 3 };
const [expandedGroups, setExpandedGroups] = useState<
@@ -60,11 +65,16 @@ export function CitySidebar() {
const sortedCities = useMemo(
() =>
[...store.cities].sort((a, b) => {
const aGroup = toRiskGroup(a.risk_level);
const bGroup = toRiskGroup(b.risk_level);
const aGroup = toPerformanceGroup(a);
const bGroup = toPerformanceGroup(b);
const aHitRate = Number(a.deb_recent_hit_rate ?? -1);
const bHitRate = Number(b.deb_recent_hit_rate ?? -1);
const aSamples = Number(a.deb_recent_sample_count ?? 0);
const bSamples = Number(b.deb_recent_sample_count ?? 0);
return (
(riskOrder[aGroup] ?? 3) -
(riskOrder[bGroup] ?? 3) ||
(riskOrder[aGroup] ?? 3) - (riskOrder[bGroup] ?? 3) ||
bHitRate - aHitRate ||
bSamples - aSamples ||
a.display_name.localeCompare(b.display_name)
);
}),
@@ -79,7 +89,7 @@ export function CitySidebar() {
other: [],
};
sortedCities.forEach((city) => {
groups[toRiskGroup(city.risk_level)].push(city);
groups[toPerformanceGroup(city)].push(city);
});
return groups;
}, [sortedCities]);
@@ -88,7 +98,7 @@ export function CitySidebar() {
if (!selectedCity) return;
const selected = store.cities.find((city) => city.name === selectedCity);
if (!selected) return;
const groupKey = toRiskGroup(selected.risk_level);
const groupKey = toPerformanceGroup(selected);
setExpandedGroups((current) =>
current[groupKey] ? current : { ...current, [groupKey]: true },
);
@@ -114,6 +124,15 @@ export function CitySidebar() {
} catch {}
}, [expandedGroups]);
const formatDeviationText = (monitor?: DeviationMonitor | null) => {
if (!monitor?.available) return "";
const label = locale === "en-US" ? monitor.label_en : monitor.label_zh;
const trendLabel =
locale === "en-US" ? monitor.trend_label_en : monitor.trend_label_zh;
if (!label) return "";
return trendLabel ? `${label} · ${trendLabel}` : label;
};
const groupMeta: Array<{ key: RiskGroupKey; label: string }> = [
{ key: "high", label: t("sidebar.group.high") },
{ key: "medium", label: t("sidebar.group.medium") },
@@ -150,10 +169,20 @@ export function CitySidebar() {
}))
}
>
<span className="city-group-title">{group.label}</span>
<span className="city-group-title">
<span
className={clsx("city-group-indicator", group.key)}
aria-hidden="true"
/>
{group.label}
</span>
<span className="city-group-meta">
<span className="city-group-count">{citiesInGroup.length}</span>
<span className={clsx("city-group-arrow", expanded && "expanded")}>
<span className="city-group-count">
{citiesInGroup.length}
</span>
<span
className={clsx("city-group-arrow", expanded && "expanded")}
>
</span>
</span>
@@ -172,6 +201,9 @@ export function CitySidebar() {
temp: `${snapshot.current.temp}${tempSymbol}`,
})
: t("common.na");
const deviationText = formatDeviationText(
snapshot?.deviation_monitor,
);
const peakTempText =
detail?.current?.max_so_far != null &&
detail.current.max_temp_time
@@ -180,8 +212,16 @@ export function CitySidebar() {
time: detail.current.max_temp_time,
})
: detail?.current?.max_temp_time
? t("sidebar.peakAt", { time: detail.current.max_temp_time })
? t("sidebar.peakAt", {
time: detail.current.max_temp_time,
})
: "";
const deviationDirection =
snapshot?.deviation_monitor?.direction || "normal";
const deviationSeverity =
snapshot?.deviation_monitor?.severity || "normal";
const secondaryText = deviationText || peakTempText;
const performanceTier = toPerformanceGroup(city);
return (
<button
@@ -195,8 +235,10 @@ export function CitySidebar() {
}
>
<div className="city-item-main">
<span className={clsx("risk-dot", city.risk_level)} />
<span className="city-name-text">{city.display_name}</span>
<span className={clsx("risk-dot", performanceTier)} />
<span className="city-name-text">
{city.display_name}
</span>
<span
className={clsx(
"city-temp",
@@ -209,9 +251,32 @@ export function CitySidebar() {
<div className="city-item-info">
<span className="city-local-time">
{snapshot?.local_time ? `🕒 ${snapshot.local_time}` : ""}
{snapshot?.local_time ? (
<>
<Clock
size={10}
strokeWidth={2}
className="city-clock-icon"
/>
{snapshot.local_time}
</>
) : (
""
)}
</span>
<span
className={clsx(
"city-max-info",
deviationText && "city-deviation-info",
deviationText &&
`city-deviation-${deviationDirection}`,
deviationText &&
deviationSeverity === "strong" &&
"strong",
)}
>
{secondaryText}
</span>
<span className="city-max-info">{peakTempText}</span>
</div>
</button>
);
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
const PolyWeatherDashboard = dynamic(
() =>
@@ -9,6 +10,7 @@ const PolyWeatherDashboard = dynamic(
),
{
ssr: false,
loading: () => <DashboardShellSkeleton />,
},
);
@@ -0,0 +1,105 @@
"use client";
import { Skeleton } from "@/components/ui/skeleton";
export function DashboardShellSkeleton() {
return (
<div
style={{
background:
"radial-gradient(circle at top, rgba(30,41,59,0.45), rgba(2,6,23,0.98) 55%)",
height: "100vh",
overflow: "hidden",
position: "relative",
width: "100vw",
}}
>
<div
style={{
alignItems: "center",
backdropFilter: "blur(16px)",
background: "rgba(10,14,26,0.78)",
borderBottom: "1px solid rgba(99,102,241,0.15)",
display: "flex",
height: 56,
justifyContent: "space-between",
left: 0,
padding: "0 24px",
position: "fixed",
right: 0,
top: 0,
zIndex: 20,
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Skeleton className="h-6 w-40 bg-zinc-700/60" />
<Skeleton className="h-3 w-28 bg-zinc-800/70" />
</div>
<div style={{ display: "flex", gap: 10 }}>
<Skeleton className="h-8 w-20 rounded-full bg-zinc-800/70" />
<Skeleton className="h-8 w-28 rounded-full bg-zinc-800/70" />
</div>
</div>
<div
style={{
bottom: 24,
display: "flex",
gap: 24,
left: 24,
position: "absolute",
right: 24,
top: 80,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 14,
maxWidth: 280,
width: "22vw",
}}
>
<Skeleton className="h-10 w-40 rounded-xl bg-zinc-800/80" />
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={index}
className="h-14 w-full rounded-2xl bg-zinc-900/75"
/>
))}
</div>
<div style={{ flex: 1, position: "relative" }}>
<Skeleton className="h-full w-full rounded-[28px] bg-zinc-950/55" />
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton
key={index}
className="absolute rounded-full bg-cyan-500/20"
style={{
height: 18,
left: `${10 + index * 10}%`,
top: `${20 + ((index * 9) % 45)}%`,
width: 18,
}}
/>
))}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 14,
maxWidth: 420,
width: "30vw",
}}
>
<Skeleton className="h-16 w-full rounded-3xl bg-zinc-900/80" />
<Skeleton className="h-48 w-full rounded-3xl bg-zinc-900/70" />
<Skeleton className="h-32 w-full rounded-3xl bg-zinc-900/70" />
</div>
</div>
</div>
);
}
+202 -113
View File
@@ -2,14 +2,16 @@
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { ForecastTable } from "@/components/dashboard/PanelSections";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
import { getCityScenery } from "@/lib/dashboard-scenery";
import { CityDetail } from "@/lib/dashboard-types";
import { trackAppEvent } from "@/lib/app-analytics";
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
import {
getCityProfileStats,
getRiskBadgeLabel,
@@ -130,20 +132,40 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
export function DetailPanel() {
const store = useDashboardStore();
const { locale, t } = useI18n();
const router = useRouter();
const detail = store.selectedDetail;
const selectedCityItem = useMemo(
() =>
store.selectedCity
? store.cities.find((city) => city.name === store.selectedCity) || null
: null,
[store.cities, store.selectedCity],
);
const selectedSummary = useMemo(
() =>
store.selectedCity
? store.citySummariesByName[store.selectedCity] || null
: null,
[store.citySummariesByName, store.selectedCity],
);
const isPro = store.proAccess.subscriptionActive;
const isAuthenticated = store.proAccess.authenticated;
const panelRef = useRef<HTMLElement | null>(null);
const [heavyContentReady, setHeavyContentReady] = useState(false);
const isOverlayOpen =
Boolean(store.futureModalDate) ||
store.historyState.isOpen ||
store.isGuideOpen;
const isVisible =
store.isPanelOpen &&
Boolean(store.selectedCity) &&
Boolean(detail) &&
!store.loadingState.cityDetail &&
!isOverlayOpen;
const isOverlayOpen = Boolean(store.futureModalDate) || store.historyState.isOpen;
const isVisible = store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
const hasBasicPanelContent = Boolean(detail || selectedSummary || selectedCityItem);
const panelDisplayName =
detail?.display_name ||
selectedSummary?.display_name ||
selectedCityItem?.display_name ||
store.selectedCity ||
"...";
const panelRiskLevel =
detail?.risk?.level ||
selectedSummary?.risk?.level ||
selectedCityItem?.risk_level ||
"low";
const profileStats = useMemo(
() => (detail ? getCityProfileStats(detail, locale) : []),
[detail, locale],
@@ -152,7 +174,23 @@ export function DetailPanel() {
() => (detail ? getOfficialSourceLinks(detail) : []),
[detail],
);
const scenery = getCityScenery(detail?.name);
const marketUrl = useMemo(
() => getTodayPolymarketUrl(detail, locale),
[detail, locale],
);
const basicSettlementLabel =
selectedSummary?.current?.settlement_source_label ||
selectedCityItem?.settlement_source_label ||
selectedCityItem?.settlement_source ||
(locale === "en-US" ? "Settlement source pending" : "结算口径待确认");
const basicAirportLabel =
selectedCityItem?.airport ||
selectedSummary?.icao ||
(locale === "en-US" ? "Airport pending" : "机场待确认");
const heroSettlementLabel =
detail?.current?.settlement_source_label || basicSettlementLabel;
const heroAirportLabel = detail?.risk?.airport || basicAirportLabel;
const blurActiveElement = () => {
if (typeof document === "undefined") return;
const active = document.activeElement;
@@ -161,16 +199,46 @@ export function DetailPanel() {
}
};
const handleFeatureAccess = (feature: "today" | "history") => {
blurActiveElement();
if (!isPro) {
trackAppEvent("paywall_feature_clicked", {
entry: "detail_panel",
feature,
city: store.selectedCity,
user_state: isAuthenticated ? "logged_in" : "guest",
});
}
if (isPro) {
if (feature === "today") {
void store.openTodayModal();
return;
}
void store.openHistory();
return;
}
if (isAuthenticated) {
router.push("/account");
return;
}
if (feature === "today") {
void store.openTodayModal();
return;
}
void store.openHistory();
};
useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
if (!isVisible) {
panel.setAttribute("inert", "");
if (
typeof document !== "undefined" &&
panel.contains(document.activeElement)
) {
if (typeof document !== "undefined" && panel.contains(document.activeElement)) {
const active = document.activeElement;
if (active instanceof HTMLElement) {
active.blur();
@@ -219,10 +287,7 @@ export function DetailPanel() {
}, [detail, isVisible]);
return (
<aside
ref={panelRef}
className={clsx("detail-panel", isVisible && "visible")}
>
<aside ref={panelRef} className={clsx("detail-panel", isVisible && "visible")}>
<div className="panel-header">
<button
type="button"
@@ -235,115 +300,134 @@ export function DetailPanel() {
>
×
</button>
<div className="panel-title-area">
<h2>{detail?.display_name?.toUpperCase() || "..."}</h2>
<div className="panel-meta">
<span className={clsx("risk-badge", detail?.risk?.level || "low")}>
{getRiskBadgeLabel(detail?.risk?.level, locale)}
</span>
<div className="relative group">
<button
type="button"
className={clsx("history-btn", !isPro && "pro-locked")}
title={
isPro
? t("detail.todayAnalysis")
: `${t("detail.todayAnalysis")} (Pro)`
}
onClick={() => {
blurActiveElement();
void store.openTodayModal();
}}
disabled={!detail}
>
{isPro
? t("detail.todayAnalysis")
: `${t("detail.todayAnalysis")} · Pro`}
</button>
<button
type="button"
className={clsx("history-btn", !isPro && "pro-locked")}
title={
isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
}
onClick={() => {
blurActiveElement();
void store.openHistory();
}}
disabled={!detail}
>
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
</button>
<div className="panel-title-area">
<div className="panel-title-stack">
<div className="panel-overline">
<span>{locale === "en-US" ? "City briefing" : "城市简报"}</span>
<span className="panel-overline-sep"></span>
<span>{panelDisplayName.toUpperCase()}</span>
</div>
<h2>{panelDisplayName}</h2>
</div>
{store.loadingState.cityDetail && (
<div className="panel-loading-hint" role="status" aria-live="polite">
<span className="panel-loading-spinner" aria-hidden="true" />
<span>
{locale === "en-US"
? `Syncing ${panelDisplayName}...`
: `正在同步 ${panelDisplayName}...`}
</span>
</div>
)}
<div className="panel-meta">
<span className={clsx("risk-badge", panelRiskLevel)}>
{getRiskBadgeLabel(panelRiskLevel, locale)}
</span>
<span className="panel-meta-chip panel-meta-chip-strong">{heroSettlementLabel}</span>
<span className="panel-meta-chip panel-meta-chip-muted">{heroAirportLabel}</span>
</div>
<div className="panel-actions">
{marketUrl ? (
<a
className="panel-action-button panel-action-button-ghost"
href={marketUrl}
target="_blank"
rel="noreferrer"
title={
locale === "en-US"
? "Open today's Polymarket market"
: "打开今日 Polymarket 题目页"
}
>
{locale === "en-US" ? "Market" : "市场页"}
</a>
) : null}
<button
type="button"
className={clsx(
"panel-action-button",
"panel-action-button-primary",
!isPro && "pro-locked",
)}
title={
isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} (Pro)`
}
onClick={() => handleFeatureAccess("today")}
disabled={!store.selectedCity}
>
{isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} · Pro`}
</button>
<button
type="button"
className={clsx(
"panel-action-button",
"panel-action-button-secondary",
!isPro && "pro-locked",
)}
title={isPro ? t("detail.history") : `${t("detail.history")} (Pro)`}
onClick={() => handleFeatureAccess("history")}
disabled={!store.selectedCity}
>
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
</button>
</div>
</div>
</div>
<div className="panel-body">
{!detail ? (
<section>
{!hasBasicPanelContent ? (
<section className="detail-summary-shell detail-empty-state">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "No city selected" : "尚未选择城市"}
</div>
<h3>{locale === "en-US" ? "Pick a city to start." : "先选择一个城市。"}</h3>
</div>
</div>
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
{store.loadingState.cityDetail
? t("detail.loading")
: t("detail.emptyHint")}
{store.loadingState.cityDetail ? t("detail.loading") : t("detail.emptyHint")}
</div>
</section>
) : !detail ? (
<div className="detail-mini-meta" role="status" aria-live="polite">
{store.loadingState.cityDetail
? locale === "en-US"
? "Loading city cards..."
: "正在加载城市卡片..."
: locale === "en-US"
? "City cards will appear here."
: "城市卡片会显示在这里。"}
</div>
) : (
<>
<section className="detail-scenery-card">
{scenery ? (
<>
<img
className="detail-scenery-image"
src={scenery.imageUrl}
alt={t("detail.sceneryAlt", { city: detail.display_name })}
/>
<div className="detail-scenery-overlay">
<div className="detail-scenery-copy">
<span className="detail-scenery-kicker">
{detail.display_name}
</span>
</div>
<a
className="detail-scenery-credit"
href={scenery.creditUrl}
target="_blank"
rel="noreferrer"
>
{scenery.creditLabel}
</a>
</div>
</>
) : (
<div className="detail-scenery-fallback">
<span className="detail-scenery-kicker">
{detail.display_name}
</span>
<strong className="detail-scenery-title">
{t("detail.sceneryTitle")}
</strong>
<span className="detail-scenery-subtitle">
{t("detail.sceneryFallback")}
</span>
<section className="detail-structured-section">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">{locale === "en-US" ? "Profile" : "城市画像"}</div>
<h3>{t("detail.profile")}</h3>
</div>
)}
</section>
<section className="detail-section">
<h3>{t("detail.profile")}</h3>
</div>
<div className="detail-grid">
{profileStats.map((item) => (
<div key={item.label} className="detail-card">
<article key={item.label} className="detail-card">
<span className="detail-label">{item.label}</span>
<span className="detail-value">{item.value}</span>
</div>
</article>
))}
</div>
</section>
{officialLinks.length > 0 ? (
<section className="detail-section">
<h3>{locale === "en-US" ? "Official Sources" : "官方参考"}</h3>
<section className="detail-structured-section">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "Primary references" : "官方参考"}
</div>
<h3>{locale === "en-US" ? "Settlement and observation references" : "结算与观测参考来源"}</h3>
</div>
</div>
<p className="detail-source-note">
{locale === "en-US"
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
@@ -358,9 +442,7 @@ export function DetailPanel() {
target="_blank"
rel="noreferrer"
>
<span className="detail-source-kind">
{link.kind.toUpperCase()}
</span>
<span className="detail-source-kind">{link.kind.toUpperCase()}</span>
<span className="detail-source-label">{link.label}</span>
</a>
))}
@@ -368,8 +450,15 @@ export function DetailPanel() {
</section>
) : null}
<section className="detail-section rounded-2xl">
<h3>{t("detail.todayMiniTrend")}</h3>
<section className="detail-structured-section rounded-2xl">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "Mini trend" : "温度微趋势"}
</div>
<h3>{t("detail.todayMiniTrend")}</h3>
</div>
</div>
{heavyContentReady ? (
<DetailMiniTemperatureChart detail={detail} />
) : (
@@ -0,0 +1,213 @@
.root :global(.panel-title-stack) {
display: grid;
gap: 8px;
}
.root :global(.panel-overline) {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.root :global(.panel-overline-sep) {
opacity: 0.45;
}
.root :global(.panel-subtitle) {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.root :global(.panel-weather-chip) {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
}
.root :global(.panel-meta-chip) {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
}
.root :global(.panel-meta-chip-strong) {
color: var(--text-primary);
background: rgba(34, 211, 238, 0.1);
border-color: rgba(34, 211, 238, 0.2);
}
.root :global(.panel-meta-chip-muted) {
color: var(--text-muted);
}
.root :global(.panel-actions) {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 14px;
}
.root :global(.panel-action-button) {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 0 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
text-decoration: none;
cursor: pointer;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.01em;
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, color 0.18s ease;
}
.root :global(.panel-action-button:hover) {
transform: translateY(-1px);
}
.root :global(.panel-action-button-primary) {
color: #05111f;
background: linear-gradient(135deg, #67e8f9, #38bdf8);
border-color: rgba(103, 232, 249, 0.3);
}
.root :global(.panel-action-button-secondary) {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
}
.root :global(.panel-action-button-ghost) {
color: var(--accent-cyan);
background: rgba(34, 211, 238, 0.08);
border-color: rgba(34, 211, 238, 0.16);
}
.root :global(.detail-summary-shell) {
background: linear-gradient(180deg, rgba(255,255,255,0.035), rgba(255,255,255,0.018));
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 18px;
padding: 16px;
box-shadow: 0 18px 42px rgba(2, 6, 23, 0.18);
}
.root :global(.panel-body section.detail-summary-shell) {
padding: 16px;
margin: 18px 0 0;
}
.root :global(.detail-summary-shell-live) {
background: linear-gradient(180deg, rgba(22, 78, 99, 0.18), rgba(15, 23, 42, 0.3));
}
.root :global(.detail-section-head) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.root :global(.detail-section-kicker) {
display: inline-block;
margin-bottom: 6px;
color: var(--accent-cyan);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.root :global(.detail-summary-main) {
display: grid;
gap: 14px;
}
.root :global(.detail-summary-hero) {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.root :global(.detail-summary-temp) {
color: var(--text-primary);
font-size: 38px;
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1;
}
.root :global(.detail-summary-supporting) {
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
}
.root :global(.detail-summary-grid) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.root :global(.detail-card) {
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
padding: 14px;
}
.root :global(.detail-card-hero) {
background: rgba(34, 211, 238, 0.08);
border-color: rgba(34, 211, 238, 0.16);
}
.root :global(.detail-card-wide) {
grid-column: span 2;
}
.root :global(.detail-callout-card) {
background: rgba(255, 255, 255, 0.025);
}
.root :global(.detail-value-muted) {
color: var(--text-secondary);
font-size: 14px;
font-weight: 600;
line-height: 1.7;
}
.root :global(.detail-structured-section) {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 18px;
padding: 16px 16px 18px;
}
.root :global(.detail-empty-state) {
color: var(--text-muted);
font-size: 13px;
line-height: 1.7;
}
File diff suppressed because it is too large Load Diff
@@ -1,105 +0,0 @@
"use client";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
const GUIDE_CARDS = {
"zh-CN": [
{
body: "Dynamic Ensemble Blending 是系统的核心预测层。它不是对 ECMWF、GFS、ICON、GEM、JMA 等模型的简单平均,而是结合近期样本表现、当前实况与城市偏置后得到的动态加权结果。",
title: "DEB 动态融合预测",
},
{
body: "右侧的结算概率分布基于 DEB 预测值与多模型离散度动态计算。μ 代表当前分布中心,会随着模型、实况和时间变化而变化,不是固定结算值。",
title: "结算概率引擎",
},
{
body: "结算源按城市市场定义:米兰(LIMC)、华沙(EPWA)、马德里(LEMD)使用机场 METAR;香港市场使用香港天文台(HKO);台北市场使用交通部中央气象署(CWA)。系统仍会保留 METAR/MGM 作为临近结构参考,并区分观测时间与接收时间。",
title: "结算点与主观测源",
},
{
body: "Ankara 不走通用城市逻辑。结算主站以 LTAC / Esenboğa 为准,周边领先信号优先参考 Turkish MGM 站网,其中 Ankara (Bölge/Center) 是重点监控站,不用 Etimesgut 代替。",
title: "Ankara 专属增强",
},
{
body: "点击多日预报后的模态框,主要用于分析下一个交易日。6-48 小时趋势以 weather.gov 和 Open-Meteo 为主;0-2 小时临近判断优先看 METAR 与周边站。",
title: "未来日期分析",
},
{
body: "历史准确率对账只统计已结算样本。网页端采用近 15 天滚动视图,不把当天尚未结算的样本算入胜率和 MAE。",
title: "历史对账规则",
},
],
"en-US": [
{
body: "Dynamic Ensemble Blending (DEB) is the core prediction layer. It is not a simple average across ECMWF/GFS/ICON/GEM/JMA, but a dynamically weighted blend adjusted by recent model performance, current observations, and city bias.",
title: "DEB Dynamic Fusion",
},
{
body: "Settlement probability distribution is dynamically computed from DEB forecast and model spread. μ is the current distribution center and shifts with model updates, observations, and time.",
title: "Settlement Probability Engine",
},
{
body: "Settlement source follows market rule by city: Milan (LIMC), Warsaw (EPWA), and Madrid (LEMD) settle on airport METAR, Hong Kong settles on HKO, and Taipei settles on CWA. METAR/MGM are still kept for intraday structure tracking with observation time vs receipt time separated.",
title: "Settlement Source Logic",
},
{
body: "Ankara does not follow the generic city path. LTAC / Esenboğa is the settlement station, with Turkish MGM network for leading signals. Ankara (Bölge/Center) is a key station and is not replaced by Etimesgut.",
title: "Ankara-specific Enhancement",
},
{
body: "The multi-day modal focuses on next-session analysis. 6-48h trend mainly relies on weather.gov and Open-Meteo; 0-2h nowcast prioritizes METAR and nearby stations.",
title: "Future-date Analysis",
},
{
body: "History reconciliation only uses settled samples. The web dashboard uses a rolling 15-day window and excludes same-day unsettled samples from hit-rate and MAE.",
title: "History Rules",
},
],
} as const;
export function GuideModal() {
const store = useDashboardStore();
const { locale, t } = useI18n();
if (!store.isGuideOpen) return null;
return (
<div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="guide-modal-title"
onClick={(event) => {
if (event.target === event.currentTarget) {
store.closeGuide();
}
}}
>
<div className="modal-content large">
<div className="modal-header">
<h2 id="guide-modal-title">{t("guide.title")}</h2>
<button
type="button"
className="modal-close"
aria-label={t("guide.closeAria")}
onClick={store.closeGuide}
>
×
</button>
</div>
<div className="modal-body">
<div className="guide-grid">
{GUIDE_CARDS[locale].map((card) => (
<div key={card.title} className="guide-card">
<h3>{card.title}</h3>
<p>{card.body}</p>
</div>
))}
</div>
<div className="guide-footer">{t("guide.footer")}</div>
</div>
</div>
</div>
);
}
+103 -50
View File
@@ -1,57 +1,79 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import { LogIn, UserRound } from "lucide-react";
import { LogIn, UserRound, RotateCw, BookOpen, Sparkles } from "lucide-react";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
function parseExpiryInfo(raw?: string | null) {
const text = String(raw || "").trim();
if (!text) return null;
const dt = new Date(text);
if (Number.isNaN(dt.getTime())) return null;
const diffMs = dt.getTime() - Date.now();
const daysLeft = Math.ceil(diffMs / 86_400_000);
return {
date: dt,
daysLeft,
expired: diffMs <= 0,
};
}
export function HeaderBar() {
const store = useDashboardStore();
const { locale, setLocale, t } = useI18n();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const supabaseReady = hasSupabasePublicEnv();
useEffect(() => {
let mounted = true;
if (!supabaseReady) {
setIsAuthenticated(false);
return;
}
const supabase = getSupabaseBrowserClient();
void supabase.auth.getSession().then(({ data }) => {
if (!mounted) return;
setIsAuthenticated(Boolean(data.session?.user?.id));
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
if (!mounted) return;
setIsAuthenticated(Boolean(session?.user?.id));
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [supabaseReady]);
const pathname = usePathname();
const isAuthenticated = store.proAccess.authenticated;
const docsHref = "/docs/intro";
const docsActive = pathname?.startsWith("/docs");
const trialPromoLabel =
locale === "en-US"
? "New users get 3-day Pro trial"
: "新用户可免费体验 3 天 Pro";
const accountHref = isAuthenticated
? "/account"
: "/auth/login?next=%2Faccount";
const accountLabel = isAuthenticated ? t("header.account") : t("header.signIn");
const accountLabel = isAuthenticated
? t("header.account")
: t("header.signIn");
const accountAria = isAuthenticated
? t("header.accountAria")
: t("header.signInAria");
const effectiveExpiry = store.proAccess.subscriptionActive
? store.proAccess.subscriptionTotalExpiresAt ||
store.proAccess.subscriptionExpiresAt
: store.proAccess.subscriptionExpiresAt;
const expiryInfo = parseExpiryInfo(effectiveExpiry);
const isTrialPlan = /trial/i.test(
String(store.proAccess.subscriptionPlanCode || ""),
);
const showRenewReminder =
isAuthenticated &&
!store.proAccess.loading &&
((store.proAccess.subscriptionActive &&
expiryInfo &&
expiryInfo.daysLeft <= 3) ||
(!store.proAccess.subscriptionActive && Boolean(expiryInfo)));
const renewReminderLabel = !showRenewReminder
? ""
: !store.proAccess.subscriptionActive
? isTrialPlan
? locale === "en-US"
? "Trial ended"
: "试用已结束"
: locale === "en-US"
? "Pro expired"
: "Pro 已到期"
: isTrialPlan
? locale === "en-US"
? `Trial ${Math.max(expiryInfo?.daysLeft || 0, 0)}d left`
: `试用剩余 ${Math.max(expiryInfo?.daysLeft || 0, 0)}`
: locale === "en-US"
? `Pro ${Math.max(expiryInfo?.daysLeft || 0, 0)}d left`
: `Pro 还剩 ${Math.max(expiryInfo?.daysLeft || 0, 0)}`;
return (
<header className="header">
@@ -61,7 +83,11 @@ export function HeaderBar() {
</div>
<div className="header-right">
<div className="lang-switch" role="group" aria-label={t("header.langAria")}>
<div
className="lang-switch"
role="group"
aria-label={t("header.langAria")}
>
<button
type="button"
className={clsx("lang-btn", locale === "zh-CN" && "active")}
@@ -78,6 +104,26 @@ export function HeaderBar() {
</button>
</div>
<Link
href={docsHref}
className={clsx("info-btn", docsActive && "active")}
title={t("header.docsAria")}
aria-label={t("header.docsAria")}
>
<BookOpen size={14} strokeWidth={2} />
{t("header.docs")}
</Link>
<Link
href="/account"
className="trial-promo-badge"
title={trialPromoLabel}
aria-label={trialPromoLabel}
>
<Sparkles size={12} strokeWidth={2} />
<span>{trialPromoLabel}</span>
</Link>
<Link
href={accountHref}
className="account-btn"
@@ -88,15 +134,19 @@ export function HeaderBar() {
<span>{accountLabel}</span>
</Link>
<button
type="button"
className="info-btn"
title={t("header.infoAria")}
aria-label={t("header.infoAria")}
onClick={store.openGuide}
>
{t("header.info")}
</button>
{showRenewReminder ? (
<Link
href="/account"
className={clsx(
"account-renew-badge",
!store.proAccess.subscriptionActive && "expired",
)}
title={renewReminderLabel}
aria-label={renewReminderLabel}
>
<span>{renewReminderLabel}</span>
</Link>
) : null}
<div className="live-badge" id="liveBadge">
<span className="pulse-dot" />
@@ -105,14 +155,17 @@ export function HeaderBar() {
<button
type="button"
className={clsx("refresh-btn", store.loadingState.refresh && "spinning")}
className={clsx(
"refresh-btn",
store.loadingState.refresh && "spinning",
)}
title={t("header.refreshAria")}
aria-label={t("header.refreshAria")}
onClick={() => void store.refreshAll()}
>
<RotateCw size={16} strokeWidth={2} />
</button>
</div>
</header>
);
}
}
+209 -39
View File
@@ -12,13 +12,23 @@ function HistoryChart() {
const store = useDashboardStore();
const { locale } = useI18n();
const { data } = useHistoryData();
const isTaipei = store.selectedCity === "taipei";
const isNoaaSettlement =
store.selectedDetail?.current?.settlement_source === "noaa" ||
store.selectedDetail?.current?.settlement_source_label === "NOAA";
const noaaStationCode = String(
store.selectedDetail?.current?.station_code ||
store.selectedDetail?.risk?.icao ||
"NOAA",
)
.trim()
.toUpperCase();
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
[data, store.selectedDetail?.local_date],
);
const hasMgm =
store.selectedCity === "ankara" &&
summary.mgmSeriesComplete &&
summary.mgms.some((value) => value != null);
const hasBestBaseline =
Boolean(summary.bestModelName) &&
@@ -34,10 +44,10 @@ function HistoryChart() {
borderColor: "#f87171",
borderWidth: 2,
data: summary.actuals,
label: isTaipei
label: isNoaaSettlement
? locale === "en-US"
? "NOAA Settled High (RCTP)"
: "NOAA 结算最高温 (RCTP)"
? `NOAA Settled High (${noaaStationCode})`
: `NOAA 结算最高温 (${noaaStationCode})`
: locale === "en-US"
? "Observed High"
: "实测最高温",
@@ -142,7 +152,7 @@ function HistoryChart() {
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [hasBestBaseline, hasMgm, isTaipei, summary, locale]);
}, [hasBestBaseline, hasMgm, isNoaaSettlement, noaaStationCode, summary, locale]);
if (!summary.recentData.length) return null;
@@ -155,15 +165,40 @@ function HistoryChart() {
export function HistoryModal() {
const store = useDashboardStore();
const { t } = useI18n();
const { data, error, isLoading, isOpen } = useHistoryData();
const { t, locale } = useI18n();
const { data, error, isLoading, isOpen, isRecordsLoading, meta } = useHistoryData();
const isPro = store.proAccess.subscriptionActive;
const isProLoading = store.proAccess.loading;
const isTaipei = store.selectedCity === "taipei";
const isNoaaSettlement =
store.selectedDetail?.current?.settlement_source === "noaa" ||
store.selectedDetail?.current?.settlement_source_label === "NOAA";
const noaaStationCode = String(
store.selectedDetail?.current?.station_code ||
store.selectedDetail?.risk?.icao ||
"NOAA",
)
.trim()
.toUpperCase();
const noaaStationName =
String(store.selectedDetail?.current?.station_name || "").trim() ||
String(store.selectedDetail?.risk?.airport || "").trim() ||
noaaStationCode;
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
[data, store.selectedDetail?.local_date],
);
const settledPeakRows = useMemo(
() =>
summary.recentData
.filter(
(row) =>
row.actual != null &&
row.actual_peak_time &&
row.deb_at_peak_minus_12h != null,
)
.reverse(),
[summary.recentData],
);
if (!isOpen) return null;
@@ -193,11 +228,40 @@ export function HistoryModal() {
) : (
<div className="modal-content history-modal">
<div className="modal-header">
<h2 id="history-modal-title">
{t("history.title", {
city: store.selectedCity?.toUpperCase() || "",
})}
</h2>
<div className="modal-title-stack">
<div className="modal-overline">
<span>{locale === "en-US" ? "Audit workspace" : "对账工作台"}</span>
<span className="modal-overline-sep"></span>
<span>{store.selectedCity?.toUpperCase() || ""}</span>
</div>
<h2 id="history-modal-title">
{t("history.title", {
city: store.selectedCity?.toUpperCase() || "",
})}
</h2>
<div className="modal-subtitle">
{locale === "en-US"
? "Observed highs, DEB path, and historical baseline consistency."
: "查看实测最高温、DEB 路径与历史基线是否一致。"}
</div>
{meta?.mode === "preview" ? (
<div className="modal-header-meta">
<span className="modal-meta-pill">
{isRecordsLoading
? locale === "en-US"
? "Full records syncing"
: "完整记录补齐中"
: meta.hasMore
? locale === "en-US"
? `Preview ${meta.previewCount}/${meta.fullCount}`
: `预览 ${meta.previewCount}/${meta.fullCount}`
: locale === "en-US"
? "Full set loaded"
: "完整记录已到齐"}
</span>
</div>
) : null}
</div>
<button
type="button"
className="modal-close"
@@ -208,38 +272,74 @@ export function HistoryModal() {
</button>
</div>
<div className="modal-body">
{isTaipei && (
<div
style={{
marginBottom: "16px",
padding: "12px 14px",
border: "1px solid rgba(56, 189, 248, 0.24)",
borderRadius: "12px",
background: "rgba(14, 165, 233, 0.08)",
color: "var(--text-secondary)",
fontSize: "13px",
lineHeight: 1.6,
}}
>
{isNoaaSettlement && (
<div className="modal-callout modal-callout-info">
{t("lang") === "en-US"
? "Taipei historical actuals are aligned to NOAA RCTP settlement rules: use the highest rounded whole-degree Celsius reading after the date is finalized."
: "台北历史对账已按 NOAA RCTP 结算口径对齐:采用该日最终完成质控后的最高整度摄氏值。"}
? `${store.selectedDetail?.display_name || store.selectedCity || "This city"} historical actuals are aligned to NOAA ${noaaStationCode} (${noaaStationName}) settlement rules: use the highest rounded whole-degree Celsius reading after the date is finalized.`
: `${store.selectedDetail?.display_name || store.selectedCity || "该城市"}历史对账已按 NOAA ${noaaStationCode}${noaaStationName}结算口径对齐:采用该日最终完成质控后的最高整度摄氏值。`}
</div>
)}
<div className="history-stats">
{isLoading ? (
<span style={{ color: "var(--text-muted)" }}>
{t("history.loading")}
</span>
) : error ? (
{isLoading ? (
<div className="history-modal-loading">
<div className="history-fetch-loading">
<div className="history-fetch-scan" aria-hidden="true">
<span className="history-fetch-ring history-fetch-ring-1" />
<span className="history-fetch-ring history-fetch-ring-2" />
<span className="history-fetch-sweep" />
<span className="history-fetch-core" />
</div>
<div className="history-fetch-bars" aria-hidden="true">
<span className="history-fetch-bar history-fetch-bar-1" />
<span className="history-fetch-bar history-fetch-bar-2" />
<span className="history-fetch-bar history-fetch-bar-3" />
<span className="history-fetch-bar history-fetch-bar-4" />
</div>
<div className="history-fetch-lines" aria-hidden="true">
<span className="history-fetch-line history-fetch-line-1" />
<span className="history-fetch-line history-fetch-line-2" />
<span className="history-fetch-line history-fetch-line-3" />
</div>
<div className="history-fetch-copy">
<strong>
{locale === "en-US"
? "Scanning archived settlement history"
: "正在扫描历史结算档案"}
</strong>
<span>
{locale === "en-US"
? "Reconciling settled highs, DEB traces, and baseline forecasts..."
: "正在对齐实测高温、DEB 轨迹与基线预报..."}
</span>
</div>
</div>
</div>
) : (
<>
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Performance snapshot" : "表现快照"}
</div>
<h3>
{locale === "en-US"
? "Recent settlement performance"
: "近期结算表现"}
</h3>
<div className="modal-section-note">
{locale === "en-US"
? "Hit rate, MAE, and baseline comparison across recent settled days."
: "查看近期已结算样本里的命中率、误差与基线对照。"}
</div>
</div>
<div className="history-stats">
{error ? (
<span style={{ color: "var(--accent-red)" }}>
{t("history.error")}
</span>
) : !summary.recentData.length ? (
) : !summary.recentData.length ? (
<span style={{ color: "var(--text-muted)" }}>
{t("history.empty")}
</span>
) : (
) : (
<>
<div className="h-stat-card">
<span className="label">{t("history.debHitRate")}</span>
@@ -280,9 +380,79 @@ export function HistoryModal() {
</span>
</div>
</>
)}
</div>
{!isLoading && !error && <HistoryChart />}
)}
</div>
{!error && <HistoryChart />}
{!error && settledPeakRows.length > 0 && (
<div className="history-peak-reference">
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Reference table" : "参考表"}
</div>
<div className="history-peak-reference-title">
{locale === "en-US"
? "Peak-12h DEB Reference (Approx.)"
: "峰值前 12 小时 DEB 参考(近似)"}
</div>
<div className="modal-section-note">
{locale === "en-US"
? "Use peak-minus-12h DEB as a fast sanity check for settled highs."
: "用峰值前 12 小时的 DEB 作为历史结算高温的快速校验。"}
</div>
</div>
<div className="history-peak-reference-scroll">
{settledPeakRows.map((row) => (
<div key={row.date} className="history-peak-reference-row">
<div className="history-peak-reference-date">
{row.date}
</div>
<div className="history-peak-reference-meta">
<div>
{locale === "en-US" ? "Peak ref" : "峰值参考"}:{" "}
<span style={{ color: "var(--text-primary)" }}>
{row.actual}
{store.selectedDetail?.temp_symbol || "°C"} @{" "}
{row.actual_peak_time}
</span>
</div>
<div>
{locale === "en-US" ? "DEB@-12h" : "峰值前12小时 DEB"}:{" "}
<span style={{ color: "var(--text-primary)" }}>
{row.deb_at_peak_minus_12h}
{store.selectedDetail?.temp_symbol || "°C"} @{" "}
{row.deb_at_peak_minus_12h_time}
</span>
</div>
<div>
{locale === "en-US" ? "Actual" : "最终实测"}:{" "}
<span style={{ color: "var(--text-primary)" }}>
{row.actual}
{store.selectedDetail?.temp_symbol || "°C"}
</span>
</div>
<div>
{locale === "en-US" ? "Error" : "误差"}:{" "}
<span
style={{
color:
(row.deb_at_peak_minus_12h_error ?? 0) > 0
? "#f59e0b"
: "#34d399",
}}
>
{row.deb_at_peak_minus_12h_error != null
? `${row.deb_at_peak_minus_12h_error > 0 ? "+" : ""}${row.deb_at_peak_minus_12h_error}${store.selectedDetail?.temp_symbol || "°C"}`
: "--"}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
</div>
)}
@@ -0,0 +1,217 @@
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion";
export interface IntradaySignalMetric {
key: string;
label: string;
value: string;
hint: string;
fill: number | null;
tone: string;
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function getToneColor(tone: string) {
if (tone === "cyan") return "#22d3ee";
if (tone === "blue") return "#60a5fa";
if (tone === "amber") return "#f59e0b";
return "#94a3b8";
}
export function IntradaySignalScene({
metrics,
score,
}: {
metrics: IntradaySignalMetric[];
score: number;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const prefersReducedMotion = usePrefersReducedMotion();
useEffect(() => {
const host = containerRef.current;
if (!host) return;
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
powerPreference: "low-power",
});
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100);
camera.position.set(0, 2.8, 7.2);
camera.lookAt(0, 1.2, 0);
const ambient = new THREE.AmbientLight(0xbfe8ff, 1.25);
const keyLight = new THREE.PointLight(0x67e8f9, 22, 18, 2);
keyLight.position.set(-3.8, 5.6, 4.8);
const warmLight = new THREE.PointLight(0xf59e0b, 12, 16, 2);
warmLight.position.set(4.2, 2.8, 4);
scene.add(ambient, keyLight, warmLight);
const stage = new THREE.Group();
scene.add(stage);
const floor = new THREE.Mesh(
new THREE.CylinderGeometry(3.3, 3.8, 0.12, 48),
new THREE.MeshStandardMaterial({
color: new THREE.Color(score >= 0 ? "#10263b" : "#2c1d12"),
emissive: new THREE.Color(score >= 0 ? "#0e7490" : "#b45309"),
emissiveIntensity: 0.18 + clamp(Math.abs(score) / 8, 0, 0.24),
metalness: 0.2,
roughness: 0.78,
}),
);
floor.position.y = -0.12;
stage.add(floor);
const ring = new THREE.Mesh(
new THREE.TorusGeometry(2.8, 0.03, 18, 100),
new THREE.MeshBasicMaterial({
color: new THREE.Color(score >= 0 ? "#22d3ee" : "#f59e0b"),
transparent: true,
opacity: 0.5,
}),
);
ring.rotation.x = Math.PI / 2;
ring.position.y = 0.03;
stage.add(ring);
const barGeometry = new THREE.BoxGeometry(0.8, 1, 0.8);
const capGeometry = new THREE.SphereGeometry(0.16, 16, 16);
const bars: Array<{
mesh: THREE.Mesh;
cap: THREE.Mesh;
glow: THREE.Mesh;
baseY: number;
targetHeight: number;
}> = [];
const xPositions = [-1.8, -0.6, 0.6, 1.8];
metrics.slice(0, 4).forEach((metric, index) => {
const height = 0.5 + ((metric.fill ?? 20) / 100) * 2.8;
const color = new THREE.Color(getToneColor(metric.tone));
const material = new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.22,
metalness: 0.14,
roughness: 0.38,
});
const mesh = new THREE.Mesh(barGeometry, material);
mesh.position.set(xPositions[index] || 0, height / 2, 0);
mesh.scale.y = height;
stage.add(mesh);
const cap = new THREE.Mesh(
capGeometry,
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.95,
}),
);
cap.position.set(mesh.position.x, height + 0.2, 0);
stage.add(cap);
const glow = new THREE.Mesh(
new THREE.CylinderGeometry(0.46, 0.58, 0.08, 32),
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.22,
}),
);
glow.position.set(mesh.position.x, 0.06, 0);
stage.add(glow);
bars.push({ mesh, cap, glow, baseY: cap.position.y, targetHeight: height });
});
const resize = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
};
resize();
host.appendChild(renderer.domElement);
const clock = new THREE.Clock();
let frameId = 0;
const renderFrame = () => {
frameId = window.requestAnimationFrame(renderFrame);
const elapsed = clock.getElapsedTime();
stage.rotation.y = Math.sin(elapsed * 0.35) * 0.16;
ring.material.opacity = 0.38 + Math.sin(elapsed * 0.8) * 0.08;
bars.forEach((bar, index) => {
const pulse = prefersReducedMotion
? 0
: Math.sin(elapsed * 1.5 + index * 0.8) * 0.08;
bar.cap.position.y = bar.baseY + pulse;
bar.glow.scale.x = 1 + Math.sin(elapsed * 1.2 + index) * 0.06;
bar.glow.scale.z = 1 + Math.sin(elapsed * 1.2 + index) * 0.06;
});
renderer.render(scene, camera);
};
const observer = new ResizeObserver(resize);
observer.observe(host);
frameId = window.requestAnimationFrame(renderFrame);
return () => {
observer.disconnect();
window.cancelAnimationFrame(frameId);
stage.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose());
} else {
child.material.dispose();
}
}
});
renderer.dispose();
if (renderer.domElement.parentNode === host) {
host.removeChild(renderer.domElement);
}
};
}, [metrics, prefersReducedMotion, score]);
return (
<div className="intraday-scene-shell">
<div ref={containerRef} className="intraday-scene-frame" aria-hidden="true" />
<div className="intraday-scene-legend">
{metrics.slice(0, 4).map((metric) => (
<div key={metric.key} className="intraday-scene-chip">
<span
className="intraday-scene-chip-dot"
style={{ backgroundColor: getToneColor(metric.tone) }}
/>
<div className="intraday-scene-chip-copy">
<strong>{metric.label}</strong>
<span>
{metric.value} · {metric.hint}
</span>
</div>
</div>
))}
</div>
</div>
);
}
+2 -2
View File
@@ -13,6 +13,7 @@ export function MapCanvas() {
citySummariesByName: store.citySummariesByName,
onClosePanel: store.closePanel,
onEnsureCityDetail: store.ensureCityDetail,
onMapInteractionChange: store.setMapInteractionActive,
onRegisterStopMotion: store.registerMapStopMotion,
onSelectCity: (cityName) => {
void store.selectCity(cityName);
@@ -21,8 +22,7 @@ export function MapCanvas() {
selectedDetail: store.selectedDetail,
suspendMotion:
Boolean(store.futureModalDate) ||
store.historyState.isOpen ||
store.isGuideOpen,
store.historyState.isOpen,
isLoadingDetail: store.loadingState.cityDetail,
});
@@ -0,0 +1,82 @@
.root :global(.modal-title-stack) {
display: grid;
gap: 8px;
min-width: 0;
}
.root :global(.modal-overline) {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.root :global(.modal-overline-sep) {
opacity: 0.5;
}
.root :global(.modal-subtitle) {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.6;
}
.root :global(.modal-header-meta) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.root :global(.modal-meta-pill) {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(34, 211, 238, 0.08);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
}
.root :global(.modal-callout) {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(255, 255, 255, 0.035);
color: var(--text-secondary);
font-size: 13px;
line-height: 1.65;
}
.root :global(.modal-callout-info) {
border-color: rgba(56, 189, 248, 0.24);
background: rgba(14, 165, 233, 0.08);
}
.root :global(.modal-section-heading) {
display: grid;
gap: 6px;
margin-bottom: 14px;
}
.root :global(.modal-section-kicker) {
color: var(--accent-cyan);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.root :global(.modal-section-note) {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.6;
}
+46 -43
View File
@@ -19,7 +19,6 @@ import {
getRiskBadgeLabel,
getTemperatureChartData,
getWeatherSummary,
parseAiAnalysis,
} from "@/lib/dashboard-utils";
function EmptyState({ text }: { text: string }) {
@@ -486,12 +485,12 @@ export function ProbabilityDistribution({
const buyNoText = toPriceCents(bucket.no_buy);
const marketTag = buyYesText
? locale === "en-US"
? `Buy Yes: ${buyYesText}`
: `买 Yes: ${buyYesText}`
? `Market ref: ${buyYesText}`
: `市场参考: ${buyYesText}`
: buyNoText
? locale === "en-US"
? `Buy No: ${buyNoText}`
: `买 No: ${buyNoText}`
? `Market hedge: ${buyNoText}`
: `市场反向: ${buyNoText}`
: null;
return (
@@ -531,23 +530,23 @@ export function ProbabilityDistribution({
Math.abs(bucketTemp - marketBucketTemp) < 0.26;
const marketTag = isMarketBucket
? locale === "en-US"
? `Buy Yes: ${marketYesText || "--"}`
: `买 Yes: ${marketYesText || "--"}`
? `Market ref: ${marketYesText || "--"}`
: `市场参考: ${marketYesText || "--"}`
: marketNoText
? locale === "en-US"
? `Buy No: ${marketNoText}`
: `买 No: ${marketNoText}`
? `Market hedge: ${marketNoText}`
: `市场反向: ${marketNoText}`
: null;
const yesPriceText = toPriceCents(marketYesPrice);
const noPriceText = toPriceCents(marketNoPrice);
const marketTagFinal = isMarketBucket
? locale === "en-US"
? `Buy Yes: ${yesPriceText || "--"}`
: `买 Yes: ${yesPriceText || "--"}`
? `Market ref: ${yesPriceText || "--"}`
: `市场参考: ${yesPriceText || "--"}`
: noPriceText
? locale === "en-US"
? `Buy No: ${noPriceText}`
: `买 No: ${noPriceText}`
? `Market hedge: ${noPriceText}`
: `市场反向: ${noPriceText}`
: marketTag;
let bucketLabel =
bucket.label || `${bucket.value}${detail.temp_symbol}`;
@@ -610,6 +609,7 @@ export function ModelForecast({
([, value]) =>
value !== null && value !== undefined && Number.isFinite(Number(value)),
);
const hasSingleModelOnly = modelEntries.length === 1;
// 如果没有任何数值,给出提示
if (modelEntries.length === 0) {
@@ -638,6 +638,19 @@ export function ModelForecast({
<section className="models-section">
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
{hasSingleModelOnly && (
<div
style={{
color: "var(--text-secondary)",
fontSize: "11px",
marginBottom: "8px",
}}
>
{locale === "en-US"
? "Single-model fallback: waiting for the rest of the model cluster."
: "当前处于单模型回退,其他模型结果还没回传。"}
</div>
)}
{modelEntries
.sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0))
.map(([name, value]) => {
@@ -711,9 +724,28 @@ export function ForecastTable() {
if (!data) return null;
const daily = data.forecast?.daily || [];
const isSparseDaily = daily.length <= 1;
const resolveForecastTemp = (date: string, fallback: number | null | undefined) => {
const debPrediction = data.multi_model_daily?.[date]?.deb?.prediction;
return debPrediction ?? fallback ?? null;
};
return (
<section className="forecast-section">
<h3>{t("forecast.title")}</h3>
{isSparseDaily && (
<div
className="forecast-inline-note"
style={{
color: "var(--text-secondary)",
fontSize: "12px",
marginBottom: "10px",
}}
>
{store.loadingState.cityDetail
? "多日预报同步中,正在刷新完整日序列。"
: "当前只收到当日预报,其他日期结果暂未回传。"}
</div>
)}
<div className="forecast-table">
{daily.length === 0 ? (
<EmptyState text={t("forecast.empty")} />
@@ -744,7 +776,7 @@ export function ForecastTable() {
: day.date.substring(5).replace("-", "/")}
</div>
<div className="f-temp">
{day.max_temp}
{resolveForecastTemp(day.date, day.max_temp)}
{data.temp_symbol}
</div>
</button>
@@ -756,35 +788,6 @@ export function ForecastTable() {
);
}
export function AiAnalysis() {
const { data } = useCityData();
const { t } = useI18n();
if (!data) return null;
const ai = parseAiAnalysis(data.ai_analysis);
return (
<section className="ai-section">
<h3>{t("section.ai")}</h3>
<div className="ai-box">
{!ai.summary && ai.bullets.length === 0 ? (
<span className="ai-placeholder">{t("section.aiEmpty")}</span>
) : (
<>
{ai.summary && <div className="ai-summary">{ai.summary}</div>}
{ai.bullets.length > 0 && (
<ul className="ai-list">
{ai.bullets.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
</>
)}
</div>
</section>
);
}
export function RiskInfo() {
const { data } = useCityData();
const { t } = useI18n();
@@ -1,8 +1,10 @@
"use client";
import { useEffect } from "react";
import clsx from "clsx";
import dynamic from "next/dynamic";
import { useEffect } from "react";
import styles from "./Dashboard.module.css";
import detailChromeStyles from "./DetailPanelChrome.module.css";
import modalChromeStyles from "./ModalChrome.module.css";
import {
DashboardStoreProvider,
useDashboardStore,
@@ -12,6 +14,16 @@ import { CitySidebar } from "@/components/dashboard/CitySidebar";
import { DetailPanel } from "@/components/dashboard/DetailPanel";
import { HeaderBar } from "@/components/dashboard/HeaderBar";
const loadHistoryModal = () =>
import("@/components/dashboard/HistoryModal").then(
(module) => module.HistoryModal,
);
const loadFutureForecastModal = () =>
import("@/components/dashboard/FutureForecastModal").then(
(module) => module.FutureForecastModal,
);
const MapCanvas = dynamic(
() =>
import("@/components/dashboard/MapCanvas").then((module) => module.MapCanvas),
@@ -21,20 +33,8 @@ const MapCanvas = dynamic(
},
);
const GuideModal = dynamic(
() =>
import("@/components/dashboard/GuideModal").then((module) => module.GuideModal),
{
ssr: false,
loading: () => null,
},
);
const HistoryModal = dynamic(
() =>
import("@/components/dashboard/HistoryModal").then(
(module) => module.HistoryModal,
),
loadHistoryModal,
{
ssr: false,
loading: () => null,
@@ -42,10 +42,7 @@ const HistoryModal = dynamic(
);
const FutureForecastModal = dynamic(
() =>
import("@/components/dashboard/FutureForecastModal").then(
(module) => module.FutureForecastModal,
),
loadFutureForecastModal,
{
ssr: false,
loading: () => null,
@@ -55,12 +52,15 @@ const FutureForecastModal = dynamic(
function DashboardScreen() {
const store = useDashboardStore();
const { t } = useI18n();
useEffect(() => {
void import("@/components/dashboard/GuideModal");
void import("@/components/dashboard/HistoryModal");
void import("@/components/dashboard/FutureForecastModal");
}, []);
const activeSummary = store.selectedCity
? store.citySummariesByName[store.selectedCity] || null
: null;
const activeCityName =
store.selectedDetail?.display_name ||
activeSummary?.display_name ||
store.cities.find((city) => city.name === store.selectedCity)?.display_name ||
store.selectedCity ||
"";
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -73,10 +73,6 @@ function DashboardScreen() {
store.closeHistory();
return;
}
if (store.isGuideOpen) {
store.closeGuide();
return;
}
if (store.isPanelOpen) {
store.closePanel();
}
@@ -91,22 +87,73 @@ function DashboardScreen() {
// Avoid full-page flashing on initial load; only show this overlay for manual refresh.
const showLoading =
store.loadingState.cities ||
store.loadingState.cityDetail ||
store.loadingState.refresh;
const showCitySyncToast =
store.loadingState.cityDetail &&
activeCityName &&
!store.selectedDetail &&
!activeSummary;
return (
<div className={styles.root}>
<div
className={clsx(
styles.root,
detailChromeStyles.root,
modalChromeStyles.root,
)}
>
<MapCanvas />
<HeaderBar />
<CitySidebar />
<DetailPanel />
{store.isGuideOpen && <GuideModal />}
{showCitySyncToast ? (
<div className="city-loading-toast" role="status" aria-live="polite">
<span className="city-loading-dot" aria-hidden="true" />
<span className="city-loading-copy">
{t("dashboard.loading")} {activeCityName}
</span>
</div>
) : null}
{store.historyState.isOpen && <HistoryModal />}
{store.futureModalDate && <FutureForecastModal />}
{showLoading && (
<div className="loading-overlay">
<div className="loading-spinner" />
<span>{t("dashboard.loading")}</span>
<div className="loading-card">
<div className="loading-clouds" aria-hidden="true">
<span className="loading-cloud loading-cloud-1" />
<span className="loading-cloud loading-cloud-2" />
</div>
<div className="loading-windfield" aria-hidden="true">
<span className="loading-windline loading-windline-1" />
<span className="loading-windline loading-windline-2" />
<span className="loading-windline loading-windline-3" />
</div>
<div className="loading-radar" aria-hidden="true">
<div className="loading-radar-core" />
<div className="loading-radar-ring loading-radar-ring-1" />
<div className="loading-radar-ring loading-radar-ring-2" />
<div className="loading-radar-sweep" />
<div className="loading-radar-blip loading-radar-blip-1" />
<div className="loading-radar-blip loading-radar-blip-2" />
</div>
<div className="loading-thermals" aria-hidden="true">
<span className="loading-thermal loading-thermal-1" />
<span className="loading-thermal loading-thermal-2" />
<span className="loading-thermal loading-thermal-3" />
<span className="loading-thermal loading-thermal-4" />
</div>
<div className="loading-drizzle" aria-hidden="true">
<span className="loading-drizzle-drop loading-drizzle-drop-1" />
<span className="loading-drizzle-drop loading-drizzle-drop-2" />
<span className="loading-drizzle-drop loading-drizzle-drop-3" />
<span className="loading-drizzle-drop loading-drizzle-drop-4" />
<span className="loading-drizzle-drop loading-drizzle-drop-5" />
</div>
<div className="loading-copy">
<strong>PolyWeather</strong>
<span>{t("dashboard.loading")}</span>
</div>
</div>
</div>
)}
</div>
@@ -1,10 +1,11 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useI18n } from "@/hooks/useI18n";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
import { trackAppEvent } from "@/lib/app-analytics";
const TELEGRAM_GROUP_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
@@ -63,6 +64,14 @@ export function ProFeaturePaywall({
? "Sign In to Unlock Pro"
: "先登录再开通 Pro";
useEffect(() => {
trackAppEvent("paywall_viewed", {
entry: "feature_gate",
feature,
user_state: isAuthenticated ? "logged_in" : "guest",
});
}, [feature, isAuthenticated]);
return (
<div className="flex w-full flex-col items-center justify-center py-6 md:py-10 z-30 p-4">
<UnlockProOverlay
@@ -0,0 +1,500 @@
"use client";
import { CSSProperties, useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion";
import { getWeatherAuraProfile } from "@/lib/weather-aura";
import styles from "./Dashboard.module.css";
function hexToRgba(hex: string, alpha: number) {
const sanitized = hex.replace("#", "");
const normalized =
sanitized.length === 3
? sanitized
.split("")
.map((char) => `${char}${char}`)
.join("")
: sanitized.padEnd(6, "0");
const numeric = Number.parseInt(normalized, 16);
const r = (numeric >> 16) & 255;
const g = (numeric >> 8) & 255;
const b = numeric & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
export function WeatherAuraLayer() {
const store = useDashboardStore();
const prefersReducedMotion = usePrefersReducedMotion();
const [isDesktop, setIsDesktop] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const aura = getWeatherAuraProfile(store.selectedDetail, store.cities);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) {
return;
}
const mediaQuery = window.matchMedia("(min-width: 1024px)");
const apply = () => {
setIsDesktop(mediaQuery.matches);
};
apply();
mediaQuery.addEventListener("change", apply);
return () => {
mediaQuery.removeEventListener("change", apply);
};
}, []);
useEffect(() => {
const host = containerRef.current;
if (!host || !isDesktop || prefersReducedMotion) {
return;
}
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
powerPreference: "low-power",
});
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
camera.position.z = 2;
const clock = new THREE.Clock();
const cleanupMaterials = new Set<THREE.Material>();
const particleGroups: Array<{
kind: "flow" | "rain" | "snow" | "fog" | "cloud";
geometry: THREE.BufferGeometry;
positions: Float32Array;
baseY: Float32Array;
drift: Float32Array;
phase: Float32Array;
material: THREE.Material;
mesh: THREE.Object3D;
}> = [];
const effectLights: THREE.Light[] = [];
const flashOverlay = new THREE.Mesh(
new THREE.PlaneGeometry(2.2, 2.2),
new THREE.MeshBasicMaterial({
color: new THREE.Color("#e0f2fe"),
transparent: true,
opacity: 0,
blending: THREE.AdditiveBlending,
}),
);
flashOverlay.position.z = -0.3;
scene.add(flashOverlay);
cleanupMaterials.add(flashOverlay.material as THREE.Material);
function createParticleField(
count: number,
pointSize: number,
opacity: number,
depthShift: number,
) {
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
const baseY = new Float32Array(count);
const drift = new Float32Array(count);
const phase = new Float32Array(count);
const primaryColor = new THREE.Color(aura.primary);
const secondaryColor = new THREE.Color(aura.secondary);
const tertiaryColor = new THREE.Color(aura.tertiary);
for (let index = 0; index < count; index += 1) {
const offset = index * 3;
const x = Math.random() * 2.6 - 1.3;
const y = Math.random() * 1.8 - 0.9;
const z = (Math.random() * 0.8 - 0.4) + depthShift;
positions[offset] = x;
positions[offset + 1] = y;
positions[offset + 2] = z;
baseY[index] = y;
drift[index] = (0.00045 + Math.random() * 0.0012) * aura.drift;
phase[index] = Math.random() * Math.PI * 2;
const mixedColor = primaryColor
.clone()
.lerp(secondaryColor, Math.random() * 0.65)
.lerp(tertiaryColor, Math.random() * 0.4);
colors[offset] = mixedColor.r;
colors[offset + 1] = mixedColor.g;
colors[offset + 2] = mixedColor.b;
}
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: pointSize,
transparent: true,
opacity,
vertexColors: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
sizeAttenuation: true,
});
const points = new THREE.Points(geometry, material);
scene.add(points);
cleanupMaterials.add(material);
particleGroups.push({
geometry,
positions,
baseY,
drift,
phase,
kind: "flow",
material,
mesh: points,
});
}
function createRainField(count: number) {
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const baseY = new Float32Array(count);
const drift = new Float32Array(count);
const phase = new Float32Array(count);
for (let index = 0; index < count; index += 1) {
const offset = index * 3;
positions[offset] = Math.random() * 2.8 - 1.4;
positions[offset + 1] = Math.random() * 2.2 - 1.1;
positions[offset + 2] = Math.random() * 0.4 - 0.2;
baseY[index] = positions[offset + 1];
drift[index] = (0.018 + Math.random() * 0.016) * aura.effectIntensity;
phase[index] = 0.004 + Math.random() * 0.004;
}
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
size: 0.018,
color: new THREE.Color("#7dd3fc"),
transparent: true,
opacity: 0.72,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geometry, material);
scene.add(points);
cleanupMaterials.add(material);
particleGroups.push({
geometry,
positions,
baseY,
drift,
phase,
kind: "rain",
material,
mesh: points,
});
}
function createSnowField(count: number) {
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const baseY = new Float32Array(count);
const drift = new Float32Array(count);
const phase = new Float32Array(count);
for (let index = 0; index < count; index += 1) {
const offset = index * 3;
positions[offset] = Math.random() * 2.8 - 1.4;
positions[offset + 1] = Math.random() * 2.1 - 1.05;
positions[offset + 2] = Math.random() * 0.35 - 0.18;
baseY[index] = positions[offset + 1];
drift[index] = (0.0045 + Math.random() * 0.0045) * aura.effectIntensity;
phase[index] = Math.random() * Math.PI * 2;
}
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
size: 0.024,
color: new THREE.Color("#f8fafc"),
transparent: true,
opacity: 0.85,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geometry, material);
scene.add(points);
cleanupMaterials.add(material);
particleGroups.push({
geometry,
positions,
baseY,
drift,
phase,
kind: "snow",
material,
mesh: points,
});
}
function createFogField(count: number) {
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const baseY = new Float32Array(count);
const drift = new Float32Array(count);
const phase = new Float32Array(count);
for (let index = 0; index < count; index += 1) {
const offset = index * 3;
positions[offset] = Math.random() * 2.6 - 1.3;
positions[offset + 1] = Math.random() * 0.9 - 0.45;
positions[offset + 2] = Math.random() * 0.45 - 0.2;
baseY[index] = positions[offset + 1];
drift[index] = (0.0014 + Math.random() * 0.001) * aura.effectIntensity;
phase[index] = Math.random() * Math.PI * 2;
}
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
size: 0.12,
color: new THREE.Color("#cbd5e1"),
transparent: true,
opacity: 0.18,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geometry, material);
scene.add(points);
cleanupMaterials.add(material);
particleGroups.push({
geometry,
positions,
baseY,
drift,
phase,
kind: "fog",
material,
mesh: points,
});
}
function createCloudField(count: number) {
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const baseY = new Float32Array(count);
const drift = new Float32Array(count);
const phase = new Float32Array(count);
for (let index = 0; index < count; index += 1) {
const offset = index * 3;
positions[offset] = Math.random() * 2.7 - 1.35;
positions[offset + 1] = Math.random() * 0.75 + 0.1;
positions[offset + 2] = Math.random() * 0.3 - 0.15;
baseY[index] = positions[offset + 1];
drift[index] = (0.0012 + Math.random() * 0.0009) * aura.effectIntensity;
phase[index] = Math.random() * Math.PI * 2;
}
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
size: 0.1,
color: new THREE.Color("#dbeafe"),
transparent: true,
opacity: 0.13,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geometry, material);
scene.add(points);
cleanupMaterials.add(material);
particleGroups.push({
geometry,
positions,
baseY,
drift,
phase,
kind: "cloud",
material,
mesh: points,
});
}
createParticleField(90, 0.018, aura.particleOpacity * 0.9, -0.1);
createParticleField(60, 0.026, aura.particleOpacity * 0.65, 0.08);
if (aura.effect === "rain" || aura.effect === "storm") {
createRainField(aura.effect === "storm" ? 240 : 170);
} else if (aura.effect === "snow") {
createSnowField(150);
} else if (aura.effect === "fog") {
createFogField(90);
} else if (aura.effect === "cloud" || aura.effect === "wind") {
createCloudField(aura.effect === "wind" ? 80 : 54);
}
if (aura.effect === "storm") {
const flashLight = new THREE.PointLight(0xdbeafe, 0, 6, 2);
flashLight.position.set(0.2, 0.9, 1.1);
scene.add(flashLight);
effectLights.push(flashLight);
}
const resize = () => {
const width = host.clientWidth || window.innerWidth;
const height = host.clientHeight || window.innerHeight;
renderer.setSize(width, height, false);
};
resize();
host.appendChild(renderer.domElement);
let frameId = 0;
let lastFrameAt = 0;
const renderFrame = (timestamp: number) => {
frameId = window.requestAnimationFrame(renderFrame);
if (timestamp - lastFrameAt < 40) {
return;
}
lastFrameAt = timestamp;
const elapsed = clock.getElapsedTime();
for (const field of particleGroups) {
for (let index = 0; index < field.baseY.length; index += 1) {
const offset = index * 3;
if (field.kind === "flow") {
let nextX = field.positions[offset] + field.drift[index];
if (nextX > 1.35) {
nextX = -1.35;
field.baseY[index] = Math.random() * 1.8 - 0.9;
}
field.positions[offset] = nextX;
field.positions[offset + 1] =
field.baseY[index] +
Math.sin(elapsed * 0.45 + field.phase[index] + nextX * 2.4) *
0.06 *
aura.intensity;
} else if (field.kind === "rain") {
let nextY = field.positions[offset + 1] - field.drift[index];
let nextX = field.positions[offset] + field.phase[index] * aura.drift;
if (nextY < -1.12 || nextX > 1.45) {
nextY = 1.15 + Math.random() * 0.25;
nextX = Math.random() * 2.9 - 1.45;
}
field.positions[offset] = nextX;
field.positions[offset + 1] = nextY;
} else if (field.kind === "snow") {
let nextY = field.positions[offset + 1] - field.drift[index];
let nextX =
field.positions[offset] +
Math.sin(elapsed * 0.9 + field.phase[index]) * 0.0024 * aura.effectIntensity;
if (nextY < -1.1) {
nextY = 1.12 + Math.random() * 0.2;
nextX = Math.random() * 2.8 - 1.4;
}
field.positions[offset] = nextX;
field.positions[offset + 1] = nextY;
} else if (field.kind === "fog" || field.kind === "cloud") {
let nextX = field.positions[offset] + field.drift[index];
if (nextX > 1.38) {
nextX = -1.38;
field.baseY[index] =
field.kind === "cloud"
? Math.random() * 0.75 + 0.1
: Math.random() * 0.9 - 0.45;
}
field.positions[offset] = nextX;
field.positions[offset + 1] =
field.baseY[index] +
Math.sin(elapsed * 0.3 + field.phase[index]) *
(field.kind === "cloud" ? 0.03 : 0.05);
}
}
field.geometry.attributes.position.needsUpdate = true;
}
if (effectLights.length > 0) {
const flashPulse = Math.max(0, Math.sin(elapsed * 2.1) - 0.78) * 20;
for (const light of effectLights) {
if (light instanceof THREE.PointLight) {
light.intensity = flashPulse;
}
}
(flashOverlay.material as THREE.MeshBasicMaterial).opacity = Math.min(
0.18,
flashPulse / 120,
);
} else {
(flashOverlay.material as THREE.MeshBasicMaterial).opacity = 0;
}
renderer.render(scene, camera);
};
frameId = window.requestAnimationFrame(renderFrame);
window.addEventListener("resize", resize);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener("resize", resize);
for (const child of [...scene.children]) {
scene.remove(child);
}
for (const field of particleGroups) {
field.geometry.dispose();
}
cleanupMaterials.forEach((material) => material.dispose());
renderer.dispose();
if (renderer.domElement.parentNode === host) {
host.removeChild(renderer.domElement);
}
};
}, [
aura.drift,
aura.intensity,
aura.particleOpacity,
aura.primary,
aura.secondary,
aura.tertiary,
aura.effect,
aura.effectIntensity,
isDesktop,
prefersReducedMotion,
]);
if (!isDesktop) {
return null;
}
const overlayStyle = {
backgroundImage: [
`radial-gradient(circle at 18% 22%, ${hexToRgba(aura.primary, 0.18 * aura.intensity)}, transparent 32%)`,
`radial-gradient(circle at 78% 20%, ${hexToRgba(aura.secondary, 0.14 * aura.intensity)}, transparent 34%)`,
`radial-gradient(circle at 52% 78%, ${hexToRgba(aura.tertiary, 0.12 * aura.intensity)}, transparent 38%)`,
aura.effect === "rain" || aura.effect === "storm"
? `linear-gradient(180deg, ${hexToRgba("#67e8f9", 0.06 * aura.effectIntensity)}, transparent 45%)`
: aura.effect === "snow"
? `linear-gradient(180deg, ${hexToRgba("#e2e8f0", 0.06 * aura.effectIntensity)}, transparent 45%)`
: aura.effect === "fog"
? `radial-gradient(circle at 50% 56%, ${hexToRgba("#cbd5e1", 0.08 * aura.effectIntensity)}, transparent 60%)`
: aura.effect === "cloud"
? `linear-gradient(180deg, ${hexToRgba("#dbeafe", 0.04 * aura.effectIntensity)}, transparent 40%)`
: "none",
].join(", "),
} as CSSProperties;
return (
<div
ref={containerRef}
aria-hidden="true"
className={styles.weatherAura}
data-reduced-motion={prefersReducedMotion ? "true" : "false"}
style={overlayStyle}
>
<div className={styles.weatherAuraScrim} />
</div>
);
}
@@ -0,0 +1,344 @@
.docsShell {
min-height: 100vh;
color: rgba(226, 232, 240, 0.94);
}
.docsHeader {
position: sticky;
top: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 24px;
background: rgba(2, 6, 23, 0.82);
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
backdrop-filter: blur(16px);
}
.brandWrap {
display: flex;
align-items: baseline;
gap: 12px;
}
.brandLink {
color: #67e8f9;
font-size: 1.9rem;
font-weight: 800;
text-decoration: none;
}
.brandSubtitle {
color: rgba(148, 163, 184, 0.88);
font-size: 0.95rem;
}
.headerActions {
display: flex;
align-items: center;
gap: 10px;
}
.headerButton,
.headerGhost {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(15, 23, 42, 0.75);
color: rgba(226, 232, 240, 0.95);
text-decoration: none;
font-size: 0.92rem;
font-weight: 600;
}
.headerGhost {
background: transparent;
}
.langSwitch {
display: flex;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 999px;
overflow: hidden;
}
.langButton {
min-width: 54px;
min-height: 38px;
border: 0;
background: transparent;
color: rgba(148, 163, 184, 0.92);
font-weight: 700;
}
.langButtonActive {
background: rgba(34, 211, 238, 0.16);
color: #67e8f9;
}
.docsFrame {
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 220px;
gap: 28px;
padding: 24px;
}
.sidebar {
position: sticky;
top: 86px;
align-self: start;
max-height: calc(100vh - 110px);
overflow: auto;
padding: 20px;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 20px;
background: rgba(15, 23, 42, 0.72);
}
.sidebarGroup + .sidebarGroup {
margin-top: 24px;
}
.sidebarTitle {
margin: 0 0 10px;
color: rgba(148, 163, 184, 0.92);
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sidebarLink {
display: block;
margin-bottom: 6px;
padding: 10px 12px;
border-radius: 12px;
color: rgba(226, 232, 240, 0.88);
text-decoration: none;
font-size: 0.95rem;
}
.sidebarLinkActive {
background: rgba(34, 211, 238, 0.12);
color: #67e8f9;
}
.content {
min-width: 0;
}
.contentInner {
padding: 12px 0 48px;
}
.pageTitle {
margin: 0;
font-size: clamp(2.1rem, 4vw, 3.2rem);
font-weight: 800;
letter-spacing: -0.03em;
}
.pageDescription {
max-width: 820px;
margin: 14px 0 0;
color: rgba(191, 219, 254, 0.9);
font-size: 1.03rem;
line-height: 1.75;
}
.section {
margin-top: 40px;
scroll-margin-top: 110px;
}
.sectionTitle {
margin: 0 0 16px;
font-size: 1.65rem;
font-weight: 750;
}
.paragraph {
margin: 0 0 16px;
color: rgba(226, 232, 240, 0.94);
line-height: 1.9;
}
.callout {
margin: 18px 0;
padding: 18px 20px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 18px;
background: rgba(15, 23, 42, 0.72);
}
.calloutInfo {
border-color: rgba(34, 211, 238, 0.24);
background: rgba(8, 47, 73, 0.26);
}
.calloutWarning {
border-color: rgba(251, 191, 36, 0.24);
background: rgba(120, 53, 15, 0.2);
}
.calloutSuccess {
border-color: rgba(52, 211, 153, 0.24);
background: rgba(6, 78, 59, 0.22);
}
.calloutTitle {
margin: 0 0 8px;
font-size: 0.98rem;
font-weight: 700;
}
.calloutText {
margin: 0;
line-height: 1.8;
}
.list {
margin: 0;
padding-left: 20px;
color: rgba(226, 232, 240, 0.94);
line-height: 1.85;
}
.list li + li {
margin-top: 8px;
}
.linkCard {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
padding: 0 16px;
border-radius: 999px;
border: 1px solid rgba(34, 211, 238, 0.34);
background: rgba(8, 47, 73, 0.3);
color: #67e8f9;
text-decoration: none;
font-weight: 700;
}
.linkCard:hover {
background: rgba(34, 211, 238, 0.12);
}
.linkCaption {
margin: 10px 0 0;
color: rgba(148, 163, 184, 0.92);
line-height: 1.75;
}
.toc {
position: sticky;
top: 86px;
align-self: start;
padding: 20px;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 20px;
background: rgba(15, 23, 42, 0.64);
}
.tocTitle {
margin: 0 0 12px;
color: rgba(148, 163, 184, 0.92);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.tocLink {
display: block;
color: rgba(226, 232, 240, 0.86);
text-decoration: none;
line-height: 1.7;
font-size: 0.92rem;
}
.mobileMenuButton {
display: none;
}
.mobileSidebarBackdrop {
display: none;
}
@media (max-width: 1200px) {
.docsFrame {
grid-template-columns: 260px minmax(0, 1fr);
}
.toc {
display: none;
}
}
@media (max-width: 900px) {
.docsHeader {
align-items: flex-start;
flex-direction: column;
}
.docsFrame {
grid-template-columns: minmax(0, 1fr);
}
.mobileMenuButton {
display: inline-flex;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 60;
width: min(320px, 88vw);
height: 100vh;
max-height: none;
border-radius: 0 20px 20px 0;
transform: translateX(-100%);
transition: transform 180ms ease-out;
}
.sidebarOpen {
transform: translateX(0);
}
.mobileSidebarBackdrop {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(2, 6, 23, 0.52);
display: block;
}
}
@media (max-width: 640px) {
.docsHeader {
padding: 16px;
}
.docsFrame {
padding: 16px;
}
.brandWrap {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.pageDescription {
font-size: 0.98rem;
}
}
+197
View File
@@ -0,0 +1,197 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import styles from "./DocsLayout.module.css";
import {
DocsLocale,
DocsPage,
DocsPageContent,
} from "@/content/docs/docs";
import { DOCS_GROUPS } from "@/content/docs/docs.config";
import { DOCS_PAGES } from "@/content/docs/docs";
import { useI18n } from "@/hooks/useI18n";
function DocsHeader() {
const { locale, setLocale } = useI18n();
return (
<header className={styles.docsHeader}>
<div className={styles.brandWrap}>
<Link href="/" className={styles.brandLink}>
PolyWeather
</Link>
<span className={styles.brandSubtitle}>
{locale === "zh-CN" ? "产品文档中心" : "Product Documentation"}
</span>
</div>
<div className={styles.headerActions}>
<Link href="/" className={styles.headerGhost}>
{locale === "zh-CN" ? "返回主站" : "Back to App"}
</Link>
<div className={styles.langSwitch} role="group" aria-label="Language switch">
<button
type="button"
className={clsx(styles.langButton, locale === "zh-CN" && styles.langButtonActive)}
onClick={() => setLocale("zh-CN")}
>
</button>
<button
type="button"
className={clsx(styles.langButton, locale === "en-US" && styles.langButtonActive)}
onClick={() => setLocale("en-US")}
>
EN
</button>
</div>
</div>
</header>
);
}
function DocsSidebar({
currentSlug,
locale,
open,
onClose,
}: {
currentSlug: string;
locale: DocsLocale;
open: boolean;
onClose: () => void;
}) {
return (
<>
{open && <button type="button" className={styles.mobileSidebarBackdrop} onClick={onClose} aria-label="Close menu" />}
<aside className={clsx(styles.sidebar, open && styles.sidebarOpen)}>
{DOCS_GROUPS.map((group) => {
const pages = DOCS_PAGES.filter((page) => page.group === group.id);
return (
<div key={group.id} className={styles.sidebarGroup}>
<div className={styles.sidebarTitle}>{group.title[locale]}</div>
{pages.map((page) => {
const title = page.content[locale].title;
const href = `/docs/${page.slug}`;
return (
<Link
key={page.slug}
href={href}
className={clsx(styles.sidebarLink, currentSlug === page.slug && styles.sidebarLinkActive)}
onClick={onClose}
>
{title}
</Link>
);
})}
</div>
);
})}
</aside>
</>
);
}
function DocsToc({ page, locale }: { page: DocsPageContent; locale: DocsLocale }) {
return (
<aside className={styles.toc}>
<div className={styles.tocTitle}>{locale === "zh-CN" ? "本页目录" : "On this page"}</div>
{page.sections.map((section) => (
<a key={section.id} href={`#${section.id}`} className={styles.tocLink}>
{section.title}
</a>
))}
</aside>
);
}
function BlockRenderer({ block }: { block: DocsPageContent["sections"][number]["blocks"][number] }) {
switch (block.type) {
case "paragraph":
return <p className={styles.paragraph}>{block.text}</p>;
case "callout":
return (
<div className={clsx(styles.callout, block.tone === "warning" && styles.calloutWarning, block.tone === "success" && styles.calloutSuccess, (!block.tone || block.tone === "info") && styles.calloutInfo)}>
{block.title ? <div className={styles.calloutTitle}>{block.title}</div> : null}
<p className={styles.calloutText}>{block.text}</p>
</div>
);
case "bullets":
case "steps":
return (
<ul className={styles.list}>
{block.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
case "link":
return (
<div>
<a
href={block.href}
target="_blank"
rel="noreferrer"
className={styles.linkCard}
>
{block.label}
</a>
{block.caption ? <p className={styles.linkCaption}>{block.caption}</p> : null}
</div>
);
case "image":
return (
<figure>
<img src={block.src} alt={block.alt} />
{block.caption ? <figcaption>{block.caption}</figcaption> : null}
</figure>
);
default:
return null;
}
}
export function DocsScreen({ page }: { page: DocsPage }) {
const pathname = usePathname();
const { locale } = useI18n();
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const localizedPage = useMemo(() => page.content[locale], [locale, page]);
const currentSlug = pathname?.split("/").filter(Boolean).at(-1) || page.slug;
return (
<div className={styles.docsShell}>
<DocsHeader />
<div className={styles.docsFrame}>
<DocsSidebar
currentSlug={currentSlug}
locale={locale}
open={mobileSidebarOpen}
onClose={() => setMobileSidebarOpen(false)}
/>
<main className={styles.content}>
<div className={styles.contentInner}>
<button type="button" className={clsx(styles.headerButton, styles.mobileMenuButton)} onClick={() => setMobileSidebarOpen(true)}>
{locale === "zh-CN" ? "打开导航" : "Open navigation"}
</button>
<h1 className={styles.pageTitle}>{localizedPage.title}</h1>
<p className={styles.pageDescription}>{localizedPage.description}</p>
{localizedPage.sections.map((section) => (
<section key={section.id} id={section.id} className={styles.section}>
<h2 className={styles.sectionTitle}>{section.title}</h2>
{section.blocks.map((block, index) => (
<BlockRenderer key={`${section.id}-${index}`} block={block} />
))}
</section>
))}
</div>
</main>
<DocsToc page={localizedPage} locale={locale} />
</div>
</div>
);
}
@@ -4,10 +4,16 @@ import { usePathname } from "next/navigation";
import { useReportWebVitals } from "next/web-vitals";
const TRACKED_METRICS = new Set(["INP", "LCP", "FCP"]);
const WEB_VITALS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_WEB_VITALS === "true";
export function WebVitalsReporter() {
const pathname = usePathname();
if (!WEB_VITALS_ENABLED) {
return null;
}
useReportWebVitals((metric) => {
if (!TRACKED_METRICS.has(metric.name)) {
return;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,269 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { RefreshCcw } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
type TruthHistoryItem = {
city: string;
display_name?: string;
target_date: string;
actual_high?: number | null;
settlement_source?: string | null;
settlement_station_code?: string | null;
settlement_station_label?: string | null;
truth_version?: string | null;
updated_by?: string | null;
truth_updated_at?: number | null;
is_final?: boolean | null;
};
type TruthHistoryPayload = {
items?: TruthHistoryItem[];
available_cities?: Array<{ city: string; name?: string }>;
filters?: {
city?: string | null;
date_from?: string | null;
date_to?: string | null;
limit?: number;
};
filtered_count?: number;
};
function formatUnixDateTime(value?: number | null) {
if (!value) return "-";
const date = new Date(value * 1000);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", { hour12: false });
}
async function readJson<T>(url: string): Promise<T> {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
const raw = await response.text();
throw new Error(`${url} -> HTTP ${response.status} ${raw.slice(0, 180)}`);
}
return response.json() as Promise<T>;
}
export function TruthHistoryDashboard() {
const [city, setCity] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [limit, setLimit] = useState("200");
const [payload, setPayload] = useState<TruthHistoryPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const url = new URL("/api/ops/truth-history", window.location.origin);
if (city.trim()) url.searchParams.set("city", city.trim());
if (dateFrom.trim()) url.searchParams.set("date_from", dateFrom.trim());
if (dateTo.trim()) url.searchParams.set("date_to", dateTo.trim());
if (limit.trim()) url.searchParams.set("limit", limit.trim());
const data = await readJson<TruthHistoryPayload>(url.toString());
setPayload(data);
} catch (loadError) {
setError(String(loadError));
} finally {
setLoading(false);
}
}, [city, dateFrom, dateTo, limit]);
useEffect(() => {
void load();
}, [load]);
const items = payload?.items || [];
const availableCities = payload?.available_cities || [];
const stats = useMemo(() => {
const uniqueCities = new Set(items.map((item) => item.city)).size;
const finalCount = items.filter((item) => item.is_final).length;
return {
rows: items.length,
filtered: payload?.filtered_count ?? items.length,
uniqueCities,
finalCount,
};
}, [items, payload?.filtered_count]);
return (
<main className="min-h-screen bg-slate-950 px-3 py-6 text-slate-100 sm:px-6 sm:py-8 lg:px-8">
<div className="mx-auto flex max-w-7xl flex-col gap-5 sm:gap-6">
<section className="rounded-3xl border border-slate-800 bg-slate-900/80 p-4 shadow-2xl backdrop-blur-xl sm:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary">Ops</Badge>
<Badge variant="secondary">Truth History</Badge>
<Link
href="/ops"
className="inline-flex items-center rounded-full border border-slate-700 bg-slate-950/70 px-3 py-1 text-xs font-semibold text-slate-300 transition hover:border-cyan-400/50 hover:text-white"
>
/ops
</Link>
</div>
<div>
<h1 className="text-2xl font-black tracking-tight sm:text-3xl"></h1>
<p className="mt-2 max-w-3xl text-sm text-slate-400">
/ `actual_high`
</p>
</div>
</div>
<Button onClick={() => void load()} disabled={loading} className="gap-2">
<RefreshCcw className="h-4 w-4" />
{loading ? "加载中" : "刷新"}
</Button>
</div>
</section>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="text-2xl font-black text-slate-100">{stats.rows}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> limit </CardDescription>
</CardHeader>
<CardContent className="text-2xl font-black text-slate-100">{stats.filtered}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="text-2xl font-black text-slate-100">{stats.uniqueCities}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Final Rows</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="text-2xl font-black text-slate-100">{stats.finalCount}</CardContent>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> city / date range </CardDescription>
</CardHeader>
<CardContent className="grid gap-3 lg:grid-cols-[1.4fr_1fr_1fr_160px_auto]">
<select
value={city}
onChange={(event) => setCity(event.target.value)}
className="rounded-2xl border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
>
<option value=""></option>
{availableCities.map((item) => (
<option key={item.city} value={item.city}>
{item.name || item.city}
</option>
))}
</select>
<input
type="date"
value={dateFrom}
onChange={(event) => setDateFrom(event.target.value)}
className="rounded-2xl border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
/>
<input
type="date"
value={dateTo}
onChange={(event) => setDateTo(event.target.value)}
className="rounded-2xl border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
/>
<input
type="number"
min={1}
max={1000}
value={limit}
onChange={(event) => setLimit(event.target.value)}
className="rounded-2xl border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
/>
<Button onClick={() => void load()} disabled={loading}>
</Button>
</CardContent>
</Card>
{error ? (
<Card className="border-rose-500/30 bg-rose-500/10">
<CardHeader>
<CardTitle className="text-rose-300"></CardTitle>
<CardDescription className="text-rose-200/80">{error}</CardDescription>
</CardHeader>
</Card>
) : null}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
`actual_high``settlement_source``station_code``truth_version``updated_by``updated_at`
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/70">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-900/80 text-xs uppercase tracking-[0.14em] text-slate-500">
<tr>
<th className="px-4 py-3">Date</th>
<th className="px-4 py-3">City</th>
<th className="px-4 py-3">Actual</th>
<th className="px-4 py-3">Source</th>
<th className="px-4 py-3">Station</th>
<th className="px-4 py-3">Version</th>
<th className="px-4 py-3">Updated By</th>
<th className="px-4 py-3">Updated At</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{items.map((item) => (
<tr key={`${item.city}-${item.target_date}`}>
<td className="px-4 py-3">{item.target_date}</td>
<td className="px-4 py-3">
<div className="font-semibold text-slate-100">{item.display_name || item.city}</div>
<div className="mt-1 text-xs text-slate-500">{item.city}</div>
</td>
<td className="px-4 py-3">
<div className="font-semibold text-slate-100">{item.actual_high ?? "-"}</div>
<div className="mt-1 text-xs text-slate-500">{item.is_final ? "final" : "non-final"}</div>
</td>
<td className="px-4 py-3">{item.settlement_source || "-"}</td>
<td className="px-4 py-3">
<div>{item.settlement_station_code || "-"}</div>
<div className="mt-1 text-xs text-slate-500">{item.settlement_station_label || "-"}</div>
</td>
<td className="px-4 py-3">{item.truth_version || "-"}</td>
<td className="px-4 py-3">{item.updated_by || "-"}</td>
<td className="px-4 py-3">{formatUnixDateTime(item.truth_updated_at)}</td>
</tr>
))}
{!items.length ? (
<tr>
<td className="px-4 py-4 text-slate-500" colSpan={8}>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</main>
);
}
@@ -156,6 +156,21 @@
max-width: 420px;
}
.trialPromo {
margin-top: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 14px;
border-radius: 999px;
border: 1px solid rgba(34, 211, 238, 0.24);
background: rgba(34, 211, 238, 0.08);
color: #a5f3fc;
font-size: 12px;
font-weight: 700;
line-height: 1.4;
}
/* ── Card grid ── */
.grid {
position: relative;
@@ -141,6 +141,11 @@ export function UnlockProOverlay({
? "High-precision weather intelligence, delivered everywhere."
: "全球最精准的高精度气象推送,全平台覆盖"}
</p>
<div className={s.trialPromo}>
{isEn
? "New users get a free 3-day Pro trial before billing."
: "新用户可先免费体验 3 天 Pro,再决定是否付费。"}
</div>
</div>
{/* ── Cards ── */}
@@ -4,7 +4,6 @@ import React from "react";
import {
BarChart2,
Target,
ShieldAlert,
Zap,
Info,
Activity,
@@ -23,7 +22,7 @@ export function AnalyticsPanel({
data,
t = {}, // Default empty for now, can be expanded via context or props
}: AnalyticsPanelProps) {
const { overview, market_scan, models, ai_analysis } = data;
const { overview, market_scan, models } = data;
const modelEntries = Object.entries(models)
.filter(([_, v]) => v !== undefined && v !== null)
@@ -222,25 +221,6 @@ export function AnalyticsPanel({
))}
</div>
</section>
{ai_analysis && (
<section className="pt-2">
<div className="flex items-center gap-2 mb-3">
<Activity className="h-3 w-3 text-amber-500" />
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-zinc-400">
AI COGNITIVE ANALYSIS
</span>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 relative overflow-hidden group">
<div className="absolute top-0 right-0 p-2 opacity-20">
<ShieldAlert className="w-8 h-8 text-amber-500" />
</div>
<p className="text-[11px] leading-relaxed text-zinc-400 relative z-10 font-medium">
{ai_analysis}
</p>
</div>
</section>
)}
</div>
{/* Execute Scan Footer */}
+24
View File
@@ -0,0 +1,24 @@
import type { DocsLocale, DocsNavGroup } from "./docs";
export const DOCS_GROUPS: DocsNavGroup[] = [
{
id: "getting-started",
title: { "zh-CN": "开始", "en-US": "Getting Started" },
},
{
id: "analysis",
title: { "zh-CN": "分析逻辑", "en-US": "Analysis Logic" },
},
{
id: "settlement",
title: { "zh-CN": "结算与数据", "en-US": "Settlement & Data" },
},
{
id: "history",
title: { "zh-CN": "历史对账", "en-US": "History & Reconciliation" },
},
];
export function getDocsGroupTitle(groupId: DocsNavGroup["id"], locale: DocsLocale) {
return DOCS_GROUPS.find((group) => group.id === groupId)?.title[locale] || groupId;
}
+506
View File
@@ -0,0 +1,506 @@
export type DocsLocale = "zh-CN" | "en-US";
export type DocsBlock =
| { type: "paragraph"; text: string }
| { type: "callout"; tone?: "info" | "warning" | "success"; title?: string; text: string }
| { type: "bullets"; items: string[] }
| { type: "steps"; items: string[] }
| { type: "link"; href: string; label: string; caption?: string }
| { type: "image"; src: string; alt: string; caption?: string };
export interface DocsSection {
id: string;
title: string;
blocks: DocsBlock[];
}
export interface DocsPageContent {
title: string;
description: string;
sections: DocsSection[];
}
export interface DocsPageMeta {
slug: string;
group: "getting-started" | "analysis" | "settlement" | "history";
}
export interface DocsPage extends DocsPageMeta {
content: Record<DocsLocale, DocsPageContent>;
}
export interface DocsNavGroup {
id: DocsPageMeta["group"];
title: Record<DocsLocale, string>;
}
export const DOCS_PAGES: DocsPage[] = [
{
slug: "intro",
group: "getting-started",
content: {
"zh-CN": {
title: "简介",
description: "PolyWeather 文档中心解释核心产品概念、结算口径和日内结构信号,帮助用户把机场锚点、官方增强层和模型判断转成可执行判断。",
sections: [
{
id: "what-is-polyweather",
title: "PolyWeather 是什么",
blocks: [
{ type: "paragraph", text: "PolyWeather 不是通用天气 App。它面向天气衍生品和温度市场,重点回答三个问题:今天最高温大概会落在哪个区间、机场或官方结算站会不会被压温、市场有没有明显错定价。" },
{ type: "callout", tone: "info", title: "产品定位", text: "主站的核心价值不是报天气,而是把模型、机场主站实况、官方增强站网、机场预报和结算规则整合成交易可用的信息。" },
],
},
{
id: "core-modules",
title: "你会在页面上看到什么",
blocks: [
{ type: "bullets", items: ["锚点状态:先确认当前机场主站实测、日内已见高点和结算时钟。", "当前节奏:把“此刻应到温度”和“机场实测”放在一张卡里,判断今天跑得快还是慢。", "当前命中胜率 / 模型区间与分歧:快速看最可能命中的温度桶,以及多模型当前分歧。", "今日日内结构信号:用近地面结构、高空结构、机场 TAF 和官方增强层解释今天还会不会继续冲高。", "历史对账:查看近 15 天已结算样本、DEB MAE 与最佳单模型表现。"] },
],
},
{
id: "how-to-read",
title: "如何快速读懂主站",
blocks: [
{ type: "steps", items: ["先看锚点状态和今日气温预测图,确认当前机场实测、DEB 预测和峰值窗口。", "再看当前节奏,判断今天是偏热、偏冷,还是基本按预期在走。", "接着看今日日内结构信号,确认边界风险、机场 vs 周边站,以及 TAF 有没有压温扰动。", "最后看当前命中胜率与模型区间,判断最热桶是不是已经被市场充分计价。"] },
],
},
],
},
"en-US": {
title: "Introduction",
description: "The PolyWeather docs explain the product's core concepts, settlement logic, and intraday structural signals so users can turn airport anchors, official nearby networks, and model context into actionable decisions.",
sections: [
{
id: "what-is-polyweather",
title: "What PolyWeather is",
blocks: [
{ type: "paragraph", text: "PolyWeather is not a generic weather app. It is built for weather derivatives and temperature markets, with one job: estimate the likely high-temperature bucket, explain whether the airport or official settlement site may get capped, and surface whether the market is mispricing that outcome." },
{ type: "callout", tone: "info", title: "Product focus", text: "The core value is not raw weather reporting. It is the conversion of models, airport-primary observations, official nearby networks, airport forecasts, and settlement rules into usable trading context." },
],
},
{
id: "core-modules",
title: "What you see on the site",
blocks: [
{ type: "bullets", items: ["Anchor status: current airport-primary observation, day-high-so-far, and the settlement clock.", "Current pace: compares where the airport should be by now versus the actual observation.", "Current hit odds / model spread: the likeliest bucket now and how far the model family is split.", "Intraday structural signal: surface structure, upper-air structure, airport TAF, and official nearby-network context around the peak window.", "History reconciliation: settled-sample MAE and hit-rate over the last 15 days."] },
],
},
{
id: "how-to-read",
title: "How to read the dashboard quickly",
blocks: [
{ type: "steps", items: ["Start with anchor status and the intraday chart to anchor current observations, DEB, and the expected peak window.", "Read the current pace card to see whether the airport is running hot, cold, or roughly on schedule.", "Use the structural signal to check boundary risk, airport versus nearby stations, and whether TAF introduces suppression risk.", "Then compare that weather structure against hit odds and the model spread."] },
],
},
],
},
},
},
{
slug: "intraday-signal",
group: "analysis",
content: {
"zh-CN": {
title: "今日日内结构信号",
description: "这页解释顶部“今日日内结构信号”如何生成,以及近地面与机场 TAF 为什么会同时出现。",
sections: [
{
id: "surface-vs-upper",
title: "近地面信号和高空结构信号的区别",
blocks: [
{ type: "paragraph", text: "近地面信号主要来自小时级温度、露点、气压、风向、降水概率和云量变化。它回答的是:在当前到峰值窗口这几个小时里,地面结构更支持继续升温,还是更容易被压住。" },
{ type: "paragraph", text: "高空结构信号主要来自高空派生字段、机场 TAF 与市场侧信息的综合判断。它回答的是:峰值窗口附近,高空和机场侧有没有新的扰动把最高温封顶。" },
],
},
{
id: "peak-window",
title: "为什么总在讲峰值窗口",
blocks: [
{ type: "paragraph", text: "PolyWeather 不按固定下午时段做判断,而是尽量围绕当天预计最高温兑现的窗口来分析。这样不同城市的峰值时间差异才不会被硬套成同一套模板。" },
{ type: "callout", tone: "success", title: "窗口感知", text: "页面里的“今日 12:00-16:00(约 5 小时,围绕峰值窗口)”就是在提示当前结构判断真正关注的时段。" },
],
},
{
id: "trade-language",
title: "交易语言怎么读",
blocks: [
{ type: "bullets", items: ["偏支持:结构仍支持继续升温,别太早押高温见顶。", "偏压制:高温继续上冲的把握不大,别盲目追热。", "先观察:现在还看不出明确方向,先等下一步走势确认。"] },
],
},
],
},
"en-US": {
title: "Intraday Structural Signal",
description: "This page explains how the intraday structural signal is built and why surface structure and airport TAF both appear in the same reading.",
sections: [
{
id: "surface-vs-upper",
title: "Surface versus upper-air structure",
blocks: [
{ type: "paragraph", text: "The surface layer comes from hourly temperature, dew point, pressure, wind, precipitation probability, and cloud-cover changes. It answers a near-term question: between now and the peak window, does the local surface setup still support more warming or does it look easier to cap?" },
{ type: "paragraph", text: "The upper-air layer combines derived profile signals, airport TAF, and market-side context. It answers a different question: around the peak window, is there a new airport-side or upper-air disturbance that could lock the high in place?" },
],
},
{
id: "peak-window",
title: "Why everything is framed around the peak window",
blocks: [
{ type: "paragraph", text: "PolyWeather does not force every city into the same afternoon template. It centers the analysis on the expected high-temperature payoff window for that city on that day, so different cities are not interpreted through the wrong hours." },
{ type: "callout", tone: "success", title: "Window-aware reading", text: "When you see a line such as “12:00-16:00 (~5h, around the peak window)”, that is the actual window driving the current structural read." },
],
},
{
id: "trade-language",
title: "How to read the trading language",
blocks: [
{ type: "bullets", items: ["Supportive: the setup still supports more warming. Do not call the high too early.", "Suppressive: further upside looks less reliable. Do not chase the high blindly.", "Wait / confirm: the setup is still mixed. Let the next move decide first."] },
],
},
],
},
},
},
{
slug: "taf-signal",
group: "analysis",
content: {
"zh-CN": {
title: "TAF 信号",
description: "TAF 不是结算温度,但它能告诉你机场侧在峰值窗口前后会不会有云雨、雷暴或风向切换,把最高温压住。",
sections: [
{
id: "what-taf-does",
title: "TAF 在 PolyWeather 里负责什么",
blocks: [
{ type: "paragraph", text: "TAF 在项目里是机场侧确认层,而不是温度主预测曲线。它主要补三类信息:峰值窗口有没有云雨压温、午后扰动是不是正在增强、机场风向是否发生阶段性切换。" },
],
},
{
id: "taf-periods",
title: "图上的 TAF 时段是什么意思",
blocks: [
{ type: "bullets", items: ["基础时段(BASE):TAF 的默认主背景天气。", "明确切换(FM):从某个时刻开始,机场预报切换到一套新天气状态。", "临时波动(TEMPO):一段时间内可能临时出现扰动,但不代表主背景永久改变。", "逐步转变(BECMG):天气不是一下子切,而是在一段时间里渐变。", "30% / 40% 风险窗(PROB30/40):风险有概率出现,不代表一定发生。"] },
],
},
{
id: "airport-suppression",
title: "什么叫机场端压温风险偏高",
blocks: [
{ type: "paragraph", text: "它的意思不是整座城市一定更冷,而是作为结算依据的机场站点,在峰值窗口里更可能因为云、阵雨或雷暴扰动,冲不到本来可能达到的更高温度。" },
{ type: "callout", tone: "warning", title: "重点区别", text: "TAF 负责告诉你机场侧未来几个小时会不会出现压温扰动,不直接等于结算温度本身。结算仍然看实际结算站点读数;页面上的官方增强站网只负责领先、偏移和空间分布判断,不会替代机场主站或官方结算站本身。" },
],
},
],
},
"en-US": {
title: "TAF Signal",
description: "TAF is not the settlement temperature itself, but it is useful for telling you whether the airport side may see clouds, showers, thunderstorms, or wind shifts that cap the high around the payoff window.",
sections: [
{
id: "what-taf-does",
title: "What TAF does inside PolyWeather",
blocks: [
{ type: "paragraph", text: "Within the product, TAF acts as an airport-side confirmation layer rather than the main temperature curve. Its job is to tell you whether clouds/rain may suppress the airport high, whether afternoon disruption is building, and whether the airport wind regime is about to shift in stages." },
],
},
{
id: "taf-periods",
title: "What the TAF timing labels mean",
blocks: [
{ type: "bullets", items: ["Base regime: the default background forecast segment.", "Hard shift (FM): a new weather regime begins from an explicit time.", "Temporary swing (TEMPO): a temporary disturbance window that does not replace the background regime permanently.", "Gradual shift (BECMG): conditions transition across a window instead of flipping instantly.", "30% / 40% risk window (PROB30/40): a probabilistic risk window, not a certainty signal."] },
],
},
{
id: "airport-suppression",
title: "What airport-side suppression risk means",
blocks: [
{ type: "paragraph", text: "It does not mean the entire city must run cooler. It means the airport station used for settlement is more likely to get capped by clouds, showers, or thunderstorm disruption during the peak window and fail to reach the next warmer bucket." },
{ type: "callout", tone: "warning", title: "Important distinction", text: "TAF explains whether the airport side may face suppressive weather over the next few hours. Settlement still comes from the actual settlement station reading, while the official nearby network is only an enhancement layer for lead/lag and spread, not a replacement anchor." },
],
},
],
},
},
},
{
slug: "settlement-sources",
group: "settlement",
content: {
"zh-CN": {
title: "结算站点说明",
description: "不同城市的结算口径不同。理解结算站点,比单纯看模型曲线更重要。",
sections: [
{
id: "why-settlement-matters",
title: "为什么先看结算站点",
blocks: [
{ type: "paragraph", text: "同样是“城市最高温”,市场真正结算看的往往不是城区平均温度,而是规则指定的机场或官方站点。交易上最常见的错觉,是把城市体感温度当成结算温度。" },
],
},
{
id: "city-rules",
title: "当前主要口径",
blocks: [
{ type: "bullets", items: ["多数机场市场:按机场 METAR 或机场主站实况结算。", "土耳其机场市场:机场主站仍以 METAR 为锚点,同时保留 Turkish MGM 作为领先结构参考。", "中国内地机场市场:机场主站仍以 METAR 为锚点,NMC 当前实况作为官方增强层,不直接替代机场结算站。", "日本 / 韩国机场市场:机场主站仍以 METAR 为锚点,同时可接入 JMA / KMA 官方增强层做领先结构参考。", "香港 / 流浮山 / 台湾等明确官方站点市场:按规则指定的官方结算站点结算,不能拿机场 TAF 或城区体感替代。"] },
],
},
{
id: "common-mistakes",
title: "最常见的误解",
blocks: [
{ type: "bullets", items: ["TAF 不是结算站点,它只告诉你机场未来有没有压温扰动。", "市场按机场结算时,城区更热不代表市场就该结到更高温桶。", "官方增强站网是领先参考层,不等于它可以替代机场主站做结算锚点。", "香港、流浮山、台湾等明确官方站点市场,不能简单套用通用机场 TAF / METAR 主链逻辑。"] },
],
},
],
},
"en-US": {
title: "Settlement Stations",
description: "Settlement rules differ by city. Understanding the settlement station matters more than staring only at model curves.",
sections: [
{
id: "why-settlement-matters",
title: "Why the settlement station comes first",
blocks: [
{ type: "paragraph", text: "A market may say “city high”, but the true settlement often comes from a designated airport or official site rather than the broader urban feel. One of the most common mistakes is to trade the city feel instead of the actual settlement station." },
],
},
{
id: "city-rules",
title: "Current primary rules",
blocks: [
{ type: "bullets", items: ["Most airport-linked markets settle on airport METAR or the airport primary observing site.", "Turkish airport markets keep METAR as the airport anchor, with Turkish MGM retained as a leading-structure reference.", "Mainland China airport markets keep METAR as the airport anchor, while NMC current observations act as an official enhancement layer rather than a direct replacement anchor.", "Japanese and Korean airport markets can keep METAR as the anchor while using JMA / KMA nearby-network observations as an official enhancement layer.", "Markets with explicitly designated official sites, such as Hong Kong, Lau Fau Shan, and Taiwan station-driven contracts, should be anchored to those official settlement stations rather than generic airport logic."] },
],
},
{
id: "common-mistakes",
title: "Common mistakes",
blocks: [
{ type: "bullets", items: ["TAF is not the settlement station. It only tells you whether airport-side suppressive weather may appear.", "If the market settles on an airport site, a hotter downtown feel does not automatically justify a warmer settlement bucket.", "The official nearby network is a lead/lag and spread layer. It should not be mistaken for the final settlement anchor unless the market explicitly names that station.", "Hong Kong, Lau Fau Shan, and Taiwan station-driven contracts should not be forced into the generic airport TAF / METAR chain."] },
],
},
],
},
},
},
{
slug: "history-reconciliation",
group: "history",
content: {
"zh-CN": {
title: "历史对账",
description: "历史对账用于看已结算样本,不用于把当天未结算的行情硬算进胜率。",
sections: [
{
id: "settled-only",
title: "为什么只看已结算样本",
blocks: [
{ type: "paragraph", text: "网页上的历史对账只统计已结算样本。当天还在交易中的市场,不会被提前算进 DEB 命中率或 MAE。这样做的目的,是避免用还没兑现的结果污染历史准确率。" },
],
},
{
id: "rolling-window",
title: "近 15 天滚动视图",
blocks: [
{ type: "paragraph", text: "网页默认展示近 15 天滚动视图,方便比较最近这轮模型状态,而不是用过长的旧样本稀释当前表现。" },
],
},
{
id: "peak-minus-12h",
title: "峰值前 12 小时 DEB 参考",
blocks: [
{ type: "paragraph", text: "这项指标用来回答一个更具体的问题:在真正出现高温之前 12 小时,DEB 当时大概有多准。它不是额外结算规则,而是一个用来观察模型是否过慢修正的参考视角。" },
{ type: "callout", tone: "info", title: "近似值说明", text: "当前峰值时间是根据历史快照链路反推的近似时间,不是逐分钟官方复盘。页面会明确标记为“参考 / 近似”。" },
],
},
],
},
"en-US": {
title: "History Reconciliation",
description: "History reconciliation is for settled samples only. It is not meant to leak same-day unsettled outcomes into historical hit-rate or MAE.",
sections: [
{
id: "settled-only",
title: "Why only settled samples count",
blocks: [
{ type: "paragraph", text: "The history panel only counts settled samples. Markets still trading on the same day are excluded from DEB hit-rate and MAE so unfinished outcomes do not contaminate the historical record." },
],
},
{
id: "rolling-window",
title: "Rolling 15-day view",
blocks: [
{ type: "paragraph", text: "The web dashboard defaults to a rolling 15-day view so the panel reflects current model behavior rather than being overly diluted by older regimes." },
],
},
{
id: "peak-minus-12h",
title: "DEB at peak minus 12 hours",
blocks: [
{ type: "paragraph", text: "This field answers a more specific question: how good was DEB roughly 12 hours before the eventual high actually printed? It is not a settlement rule, but a way to judge whether the model corrected too slowly." },
{ type: "callout", tone: "info", title: "Approximation note", text: "The current peak time is inferred from the snapshot chain and should be treated as an approximate reference rather than a minute-perfect official replay." },
],
},
],
},
},
},
{
slug: "extension",
group: "getting-started",
content: {
"zh-CN": {
title: "浏览器插件",
description: "PolyWeather Side Panel 是一个面向天气交易场景的浏览器侧边栏工具,负责自动识别城市、展示简版走势与城市档案,并把用户导回完整分析页面。",
sections: [
{
id: "extension-install",
title: "安装地址",
blocks: [
{
type: "link",
href: "https://chromewebstore.google.com/detail/mhndjbgjljjfcfkojhmhpfcbconnikne?utm_source=item-share-cb",
label: "打开 Chrome Web Store",
caption: "安装插件后,可在侧边栏里快速跳回主站的今日日内分析与历史对账。",
},
],
},
{
id: "extension-role",
title: "插件负责什么",
blocks: [
{
type: "bullets",
items: [
"自动识别当前 Polymarket 页面中的城市,也支持手动切换。",
"展示城市档案:结算站点、站点距离、观测更新时间、周边站点数量。",
"展示今日日内走势(简版):DEB 走势与机场主站实况 / 官方增强站网对照,可悬停查看时间与温度。",
"展示多日最高温预报(简版),并提供一键刷新与跳转主站入口。",
],
},
],
},
{
id: "extension-permission",
title: "权限与隐私",
blocks: [
{
type: "bullets",
items: [
"`tabs`:用于识别当前活动标签页 URL 并自动匹配城市。",
"`storage`:用于保存插件配置与本地缓存,仅存储在本地浏览器。",
"`sidePanel`:用于在浏览器侧边栏展示界面。",
"插件不要求用户登录,不收集个人身份信息,不上传浏览历史,仅在必要时请求天气接口数据。",
],
},
],
},
{
id: "extension-boundary",
title: "插件不负责什么",
blocks: [
{
type: "paragraph",
text: "插件不承担完整分析体验,也不承载支付链路。复杂结构判断、历史对账和完整交易语境仍以主站为准。",
},
{
type: "callout",
tone: "info",
title: "当前定位",
text: "插件是“监控 + 基础判断 + 导流回站”的轻量产品,而不是主站的 1:1 复制品。",
},
],
},
{
id: "extension-forecast",
title: "当前多日预报口径",
blocks: [
{
type: "paragraph",
text: "插件的多日预报已改为 DEB 优先显示。只有某一天没有 DEB 值时,才回退到原始的日最高温预报值。",
},
],
},
],
},
"en-US": {
title: "Browser Extension",
description: "PolyWeather Side Panel is a browser side-panel tool for weather trading workflows. It auto-detects cities, shows compact intraday and city-profile context, and routes users back to the full dashboard.",
sections: [
{
id: "extension-install",
title: "Install link",
blocks: [
{
type: "link",
href: "https://chromewebstore.google.com/detail/mhndjbgjljjfcfkojhmhpfcbconnikne?utm_source=item-share-cb",
label: "Open Chrome Web Store",
caption: "Once installed, the side panel can route users back into the main intraday analysis and history views.",
},
],
},
{
id: "extension-role",
title: "What the extension does",
blocks: [
{
type: "bullets",
items: [
"Auto-detects the current Polymarket page city, with manual switching also available.",
"Shows a city profile with settlement station, station distance, observation timestamp, and nearby station count.",
"Shows a compact intraday chart with DEB versus airport-primary observations and official nearby-network observations, including hoverable time and temperature.",
"Shows a compact multi-day daily-high forecast, plus refresh and jump-to-site actions.",
],
},
],
},
{
id: "extension-permission",
title: "Permissions and privacy",
blocks: [
{
type: "bullets",
items: [
"`tabs`: used to inspect the active tab URL and match the current city.",
"`storage`: used for local configuration and local cache only.",
"`sidePanel`: used to render the browser side panel UI.",
"The extension does not require login, does not collect personally identifiable information, and does not upload browsing history. It only requests weather endpoints when needed to render the panel.",
],
},
],
},
{
id: "extension-boundary",
title: "What it does not do",
blocks: [
{
type: "paragraph",
text: "The extension does not attempt to replicate the full analysis stack and does not carry the payment flow. Deeper structural reasoning and full trade context still live on the main site.",
},
{
type: "callout",
tone: "info",
title: "Current positioning",
text: "Think of the extension as monitoring plus lightweight bias, not as a full dashboard replacement.",
},
],
},
{
id: "extension-forecast",
title: "Current forecast logic",
blocks: [
{
type: "paragraph",
text: "The extension now prefers DEB for the multi-day forecast. It falls back to the original daily max only when a DEB value is missing for that date.",
},
],
},
],
},
},
},
];
export function getDocsPage(slug: string) {
return DOCS_PAGES.find((page) => page.slug === slug) || null;
}
+10 -1
View File
@@ -3,6 +3,15 @@
import { useEffect, useRef } from "react";
import type { Chart as ChartInstance, ChartConfiguration, ChartType } from "chart.js";
let chartModulePromise: Promise<typeof import("chart.js/auto")> | null = null;
export function preloadChartJs() {
if (!chartModulePromise) {
chartModulePromise = import("chart.js/auto");
}
return chartModulePromise;
}
export function useChart<TType extends ChartType>(
createConfig: () => ChartConfiguration<TType>,
dependencies: React.DependencyList,
@@ -16,7 +25,7 @@ export function useChart<TType extends ChartType>(
let disposed = false;
const setupChart = async () => {
const { Chart } = await import("chart.js/auto");
const { Chart } = await preloadChartJs();
if (disposed) return;
const config = createConfig();
File diff suppressed because it is too large Load Diff
+310 -50
View File
@@ -8,7 +8,7 @@ import {
CitySummary,
NearbyStation,
} from "@/lib/dashboard-types";
import { pickAnkaraNearbyStations } from "@/lib/dashboard-utils";
import { pickMapNearbyStations } from "@/lib/dashboard-utils";
interface UseLeafletMapArgs {
cities: CityListItem[];
@@ -18,7 +18,9 @@ interface UseLeafletMapArgs {
onEnsureCityDetail: (
cityName: string,
force?: boolean,
depth?: "panel" | "nearby" | "full",
) => Promise<CityDetail>;
onMapInteractionChange: (active: boolean) => void;
onRegisterStopMotion: (stopMotion: () => void) => void;
onSelectCity: (cityName: string) => void;
selectedCity: string | null;
@@ -29,7 +31,60 @@ interface UseLeafletMapArgs {
const AUTO_NEARBY_MIN_ZOOM = 8;
const AUTO_NEARBY_MAX_DISTANCE_M = 120000;
const AUTO_NEARBY_IDLE_REFRESH_DELAY_MS = 10_000;
const AUTO_NEARBY_MIN_REFRESH_INTERVAL_MS = 60_000;
const MAP_MAX_ZOOM = 19;
const CITY_MARKER_DISPLAY_OFFSETS: Record<
string,
{ x: number; y: number; zIndexOffset?: number }
> = {
// Shek Kong sits between the Hong Kong and Shenzhen cards and gets visually buried
// by their wide marker bubbles. Shift only the rendered marker, not the true point.
"shek kong": { x: 34, y: -26, zIndexOffset: 320 },
"lau fau shan": { x: -40, y: 14, zIndexOffset: 300 },
};
function getMarkerDisplayOffset(cityName: string) {
return CITY_MARKER_DISPLAY_OFFSETS[String(cityName || "").toLowerCase()] || {
x: 0,
y: 0,
zIndexOffset: 0,
};
}
function pickMarkerTemperature(
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
) {
if (!snapshot) return null;
const detail = snapshot as Partial<CityDetail>;
const currentSource = String(
snapshot.current?.settlement_source ||
snapshot.current?.settlement_source_label ||
"",
)
.trim()
.toLowerCase();
const currentTemp =
currentSource === "nmc" || currentSource.includes("nmc")
? null
: snapshot.current?.temp;
const isNmcStation = (station?: { source_label?: string | null }) =>
String(station?.source_label || "")
.trim()
.toLowerCase()
.includes("nmc");
const candidates = [
currentTemp,
detail.airport_current?.temp,
isNmcStation(detail.airport_primary) ? null : detail.airport_primary?.temp,
detail.mgm_nearby?.[0]?.temp,
];
for (const value of candidates) {
const numeric = Number(value);
if (Number.isFinite(numeric)) return numeric;
}
return null;
}
function createMarkerIcon(
city: CityListItem,
@@ -39,13 +94,18 @@ function createMarkerIcon(
const label = city.display_name;
const unit = city.temp_unit === "fahrenheit" ? "°F" : "°C";
const shortName = label.length > 10 ? `${label.substring(0, 8)}...` : label;
const tempText =
snapshot?.current?.temp != null ? `${snapshot.current.temp}${unit}` : "--";
const markerTemp = pickMarkerTemperature(snapshot);
const tempText = markerTemp != null ? `${markerTemp}${unit}` : "--";
const offset = getMarkerDisplayOffset(city.name);
const styleAttr =
offset.x || offset.y
? ` style="transform: translate(${offset.x}px, ${offset.y}px);"`
: "";
return L.divIcon({
className: "",
html: `
<div class="city-marker" data-city="${city.name}">
<div class="city-marker" data-city="${city.name}"${styleAttr}>
<div class="marker-bubble ${riskClass}">${tempText}</div>
<div class="marker-name">${shortName}</div>
</div>
@@ -55,9 +115,42 @@ function createMarkerIcon(
});
}
function getMarkerSignature(
city: CityListItem,
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
) {
return [
city.display_name,
city.risk_level,
city.temp_unit,
city.lat,
city.lon,
pickMarkerTemperature(snapshot) ?? "",
].join("|");
}
function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
const sanitizeWindText = (value?: string | null) => {
const text = String(value || "").trim();
if (!text || text === "9999") return "";
return text;
};
const symbol = detail.temp_symbol || "°C";
const rawLabel =
station.station_label ||
station.name ||
station.station_code ||
station.icao ||
"实测 (OBS)";
const label =
String(station.source_code || station.source_label || "").trim().toLowerCase() === "nmc" &&
/\(NMC\)$/i.test(String(rawLabel)) &&
!String(rawLabel).includes("区域实况")
? String(rawLabel).replace(/\s*\(NMC\)$/i, "区域实况 (NMC)")
: rawLabel;
let windHtml = "";
const windDirectionText = sanitizeWindText(station.wind_direction_text);
const windPowerText = sanitizeWindText(station.wind_power_text);
if (station.wind_dir != null) {
const rotation = (Number(station.wind_dir) + 180) % 360;
@@ -69,6 +162,13 @@ function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
<span class="wind-val">${speed}</span>
</div>
`;
} else if (windDirectionText || windPowerText) {
const windText = [windDirectionText, windPowerText].filter(Boolean).join(" ");
windHtml = `
<div class="nearby-wind">
<span class="wind-val">${windText}</span>
</div>
`;
}
return `
@@ -78,7 +178,7 @@ function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
<div class="pulse-core"></div>
</div>
<div class="nearby-content">
<span class="nearby-label">${station.name || station.icao || "OBS"}</span>
<span class="nearby-label">${label}</span>
<div class="nearby-stats">
<span class="nearby-temp-val">${station.temp ?? "--"}</span>
<span class="nearby-temp-unit">${symbol}</span>
@@ -89,12 +189,51 @@ function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
`;
}
function getNearbyMarkerDisplayOffset(
detail: CityDetail,
station: NearbyStation,
index: number,
) {
const cityLat = Number(detail.lat);
const cityLon = Number(detail.lon);
const stationLat = Number(station.lat);
const stationLon = Number(station.lon);
if (
!Number.isFinite(cityLat) ||
!Number.isFinite(cityLon) ||
!Number.isFinite(stationLat) ||
!Number.isFinite(stationLon)
) {
return { x: 0, y: 0 };
}
const latDiff = Math.abs(cityLat - stationLat);
const lonDiff = Math.abs(cityLon - stationLon);
const isNearCityAnchor = latDiff < 0.02 && lonDiff < 0.02;
if (!isNearCityAnchor) {
return { x: 0, y: 0 };
}
const presets = [
{ x: 0, y: -58 },
{ x: 76, y: -34 },
{ x: -76, y: -34 },
{ x: 72, y: 34 },
{ x: -72, y: 34 },
];
return presets[index % presets.length];
}
export function useLeafletMap({
cities,
cityDetailsByName,
citySummariesByName,
onClosePanel,
onEnsureCityDetail,
onMapInteractionChange,
onRegisterStopMotion,
onSelectCity,
selectedCity,
@@ -118,6 +257,14 @@ export function useLeafletMap({
const onRegisterStopMotionRef = useRef(onRegisterStopMotion);
const onSelectCityRef = useRef(onSelectCity);
const onEnsureCityDetailRef = useRef(onEnsureCityDetail);
const onMapInteractionChangeRef = useRef(onMapInteractionChange);
const interactionIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const nearbyRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const lastNearbyRefreshAtRef = useRef<Record<string, number>>({});
useEffect(() => {
onClosePanelRef.current = onClosePanel;
@@ -135,6 +282,10 @@ export function useLeafletMap({
onEnsureCityDetailRef.current = onEnsureCityDetail;
}, [onEnsureCityDetail]);
useEffect(() => {
onMapInteractionChangeRef.current = onMapInteractionChange;
}, [onMapInteractionChange]);
useEffect(() => {
suspendMotionRef.current = suspendMotion;
}, [suspendMotion]);
@@ -178,8 +329,50 @@ export function useLeafletMap({
};
map.on("click", handleMapClick);
const markInteracting = () => {
if (interactionIdleTimerRef.current) {
clearTimeout(interactionIdleTimerRef.current);
interactionIdleTimerRef.current = null;
}
if (nearbyRefreshTimerRef.current) {
clearTimeout(nearbyRefreshTimerRef.current);
nearbyRefreshTimerRef.current = null;
}
onMapInteractionChangeRef.current(true);
};
const markIdleSoon = () => {
if (interactionIdleTimerRef.current) {
clearTimeout(interactionIdleTimerRef.current);
}
interactionIdleTimerRef.current = setTimeout(() => {
interactionIdleTimerRef.current = null;
onMapInteractionChangeRef.current(false);
}, 700);
};
map.on("movestart", markInteracting);
map.on("zoomstart", markInteracting);
map.on("dragstart", markInteracting);
map.on("moveend", markIdleSoon);
map.on("zoomend", markIdleSoon);
return () => {
onRegisterStopMotionRef.current(() => {});
if (interactionIdleTimerRef.current) {
clearTimeout(interactionIdleTimerRef.current);
interactionIdleTimerRef.current = null;
}
if (nearbyRefreshTimerRef.current) {
clearTimeout(nearbyRefreshTimerRef.current);
nearbyRefreshTimerRef.current = null;
}
onMapInteractionChangeRef.current(false);
map.off("movestart", markInteracting);
map.off("zoomstart", markInteracting);
map.off("dragstart", markInteracting);
map.off("moveend", markIdleSoon);
map.off("zoomend", markIdleSoon);
map.off("click", handleMapClick);
map.remove();
mapRef.current = null;
@@ -209,7 +402,7 @@ export function useLeafletMap({
}, [cities]);
const lastCityDataRef = useRef<
Record<string, { temp?: number | null; risk?: string }>
Record<string, string>
>({});
// Handle marker synchronization
@@ -223,57 +416,60 @@ export function useLeafletMap({
if (canceled) return;
const currentMarkers = markersRef.current;
const nextMarkers: typeof currentMarkers = {};
const nextLastData: typeof lastCityDataRef.current = {};
const cityNames = new Set(cities.map((city) => city.name));
Object.entries(currentMarkers).forEach(([name, entry]) => {
if (cityNames.has(name)) return;
map.removeLayer(entry.marker);
delete currentMarkers[name];
delete lastCityDataRef.current[name];
});
cities.forEach((city) => {
const detail = cityDetailsByName[city.name];
const summary = citySummariesByName[city.name];
const snapshot = detail || summary;
const existing = currentMarkers[city.name];
const currentTemp = snapshot?.current?.temp;
const currentRisk = city.risk_level;
const lastData = lastCityDataRef.current[city.name];
const dataChanged =
!lastData ||
lastData.temp !== currentTemp ||
lastData.risk !== currentRisk;
const signature = getMarkerSignature(city, snapshot);
const previousSignature = lastCityDataRef.current[city.name];
if (existing) {
if (dataChanged) {
if (existing.city.lat !== city.lat || existing.city.lon !== city.lon) {
existing.marker.setLatLng([city.lat, city.lon]);
}
if (previousSignature !== signature) {
existing.marker.setIcon(createMarkerIcon(city, snapshot));
}
nextMarkers[city.name] = { city, marker: existing.marker };
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
currentMarkers[city.name] = { city, marker: existing.marker };
lastCityDataRef.current[city.name] = signature;
return;
}
// Create new marker
const marker = L.marker([city.lat, city.lon], {
icon: createMarkerIcon(city, snapshot),
zIndexOffset: getMarkerDisplayOffset(city.name).zIndexOffset || 0,
}).addTo(map);
marker.on("click", () => {
map.stop();
// Reset lastMovedCity so we can re-fly if needed
lastMovedCityRef.current = null;
const currentMap = mapRef.current;
currentMap?.stop();
if (currentMap && !suspendMotion) {
currentMap.flyTo([city.lat, city.lon], 11, {
animate: true,
duration: 1.05,
easeLinearity: 0.22,
});
lastMovedCityRef.current = city.name;
} else {
lastMovedCityRef.current = null;
}
onSelectCityRef.current(city.name);
});
nextMarkers[city.name] = { city, marker };
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
currentMarkers[city.name] = { city, marker };
lastCityDataRef.current[city.name] = signature;
});
// Cleanup removed markers
Object.entries(currentMarkers).forEach(([name, entry]) => {
if (!nextMarkers[name]) {
map.removeLayer(entry.marker);
}
});
markersRef.current = nextMarkers;
lastCityDataRef.current = nextLastData;
})
: null;
@@ -298,17 +494,17 @@ export function useLeafletMap({
if (!mapRef.current || !nearbyLayerRef.current) return;
const map = mapRef.current;
const layer = nearbyLayerRef.current;
const clearNearbyRefreshTimer = () => {
if (nearbyRefreshTimerRef.current) {
clearTimeout(nearbyRefreshTimerRef.current);
nearbyRefreshTimerRef.current = null;
}
};
function renderNearbyStations(detail: CityDetail, preserveView = false) {
layer.clearLayers();
const allNearby = Array.isArray(detail.mgm_nearby)
? detail.mgm_nearby
: [];
const nearbyStations =
String(detail.name || "").toLowerCase() === "ankara"
? pickAnkaraNearbyStations(allNearby)
: allNearby;
const nearbyStations = pickMapNearbyStations(detail);
if (!nearbyStations.length) {
if (!preserveView && detail.lat != null && detail.lon != null) {
@@ -331,15 +527,30 @@ export function useLeafletMap({
const sLon = Number(station.lon);
// Ignore invalid (0,0) or null coordinates which cause global zoom-out
if (!Number.isFinite(sLat) || !Number.isFinite(sLon)) return;
if (Math.abs(sLat) < 0.1 && Math.abs(sLon) < 0.1) return;
if (Math.abs(sLat) < 0.1 && Math.abs(sLon) < 0.1) return;
const displayOffset = getNearbyMarkerDisplayOffset(detail, station, latLngs.length);
const styleAttr =
displayOffset.x || displayOffset.y
? ` style="transform: translate(${displayOffset.x}px, ${displayOffset.y}px);"`
: "";
const icon = L.divIcon({
className: "",
html: buildNearbyIconHtml(detail, station),
html: `
<div class="nearby-marker-shell"${styleAttr}>
${buildNearbyIconHtml(detail, station)}
</div>
`,
iconAnchor: [16, 19],
iconSize: [240, 38],
});
L.marker([sLat, sLon], { icon }).addTo(layer);
L.marker([sLat, sLon], {
icon,
interactive: false,
keyboard: false,
bubblingMouseEvents: false,
}).addTo(layer);
latLngs.push([sLat, sLon]);
});
@@ -349,6 +560,39 @@ export function useLeafletMap({
// This section is primarily for auto-discovery movement if needed.
}
function scheduleIdleNearbyRefresh(targetCity: string | null) {
clearNearbyRefreshTimer();
if (!targetCity || suspendMotion) return;
if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) return;
nearbyRefreshTimerRef.current = setTimeout(async () => {
nearbyRefreshTimerRef.current = null;
if (loadingAutoNearbyRef.current || handlingAutoNearbyRef.current) return;
if (!mapRef.current || mapRef.current.getZoom() < AUTO_NEARBY_MIN_ZOOM) return;
if (autoNearbyCityRef.current !== targetCity) return;
const lastRefreshAt = lastNearbyRefreshAtRef.current[targetCity] || 0;
if (Date.now() - lastRefreshAt < AUTO_NEARBY_MIN_REFRESH_INTERVAL_MS) {
return;
}
loadingAutoNearbyRef.current = true;
lastNearbyRefreshAtRef.current[targetCity] = Date.now();
try {
const detail = await onEnsureCityDetailRef.current(
targetCity,
true,
"nearby",
);
if (autoNearbyCityRef.current !== targetCity) return;
renderNearbyStations(detail, true);
} catch {
} finally {
loadingAutoNearbyRef.current = false;
}
}, AUTO_NEARBY_IDLE_REFRESH_DELAY_MS);
}
async function maybeAutoShowNearbyStations() {
if (handlingAutoNearbyRef.current) {
return;
@@ -356,12 +600,18 @@ export function useLeafletMap({
handlingAutoNearbyRef.current = true;
try {
if (selectedDetail) {
// Just render stations, no camera move from here
renderNearbyStations(selectedDetail, true);
return;
const selectedNearbyStations = pickMapNearbyStations(selectedDetail);
if (selectedNearbyStations.length) {
// Just render stations, no camera move from here
renderNearbyStations(selectedDetail, true);
autoNearbyCityRef.current = selectedCity || selectedDetail.name || null;
scheduleIdleNearbyRefresh(autoNearbyCityRef.current);
return;
}
}
if (suspendMotion) {
clearNearbyRefreshTimer();
return;
}
@@ -370,6 +620,7 @@ export function useLeafletMap({
if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) {
autoNearbyCityRef.current = null;
clearNearbyRefreshTimer();
layer.clearLayers();
return;
}
@@ -387,9 +638,10 @@ export function useLeafletMap({
}
}
const targetCity = best?.cityName || null;
const targetCity = selectedCity || best?.cityName || null;
if (!targetCity) {
autoNearbyCityRef.current = null;
clearNearbyRefreshTimer();
layer.clearLayers();
return;
}
@@ -398,21 +650,28 @@ export function useLeafletMap({
autoNearbyCityRef.current === targetCity &&
layer.getLayers().length > 0
) {
scheduleIdleNearbyRefresh(targetCity);
return;
}
autoNearbyCityRef.current = targetCity;
const cachedDetail = cityDetailsByName[targetCity];
if (cachedDetail) {
if (cachedDetail && pickMapNearbyStations(cachedDetail).length) {
renderNearbyStations(cachedDetail, true);
scheduleIdleNearbyRefresh(targetCity);
return;
}
if (loadingAutoNearbyRef.current) return;
loadingAutoNearbyRef.current = true;
try {
const detail = await onEnsureCityDetailRef.current(targetCity, false);
const detail = await onEnsureCityDetailRef.current(
targetCity,
false,
"nearby",
);
renderNearbyStations(detail, true);
scheduleIdleNearbyRefresh(targetCity);
} catch {
} finally {
loadingAutoNearbyRef.current = false;
@@ -438,6 +697,7 @@ export function useLeafletMap({
map.on("moveend", maybeAutoShowNearbyStations);
return () => {
clearNearbyRefreshTimer();
map.off("zoomend", syncVisibility);
map.off("moveend", maybeAutoShowNearbyStations);
};
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useEffect, useState } from "react";
export function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) {
return;
}
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const apply = () => {
setPrefersReducedMotion(mediaQuery.matches);
};
apply();
mediaQuery.addEventListener("change", apply);
return () => {
mediaQuery.removeEventListener("change", apply);
};
}, []);
return prefersReducedMotion;
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
const ANALYTICS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true";
type TrackableAnalyticsEvent =
| "signup_completed"
| "dashboard_active"
| "paywall_feature_clicked"
| "paywall_viewed"
| "checkout_started"
| "checkout_succeeded";
const CLIENT_ID_KEY = "polyweather:analytics:client-id";
const SESSION_ID_KEY = "polyweather:analytics:session-id";
function isClient() {
return typeof window !== "undefined";
}
function randomId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function getStoredId(storage: Storage, key: string) {
let value = storage.getItem(key);
if (!value) {
value = randomId();
storage.setItem(key, value);
}
return value;
}
export function getAnalyticsClientId() {
if (!isClient()) return "";
try {
return getStoredId(window.localStorage, CLIENT_ID_KEY);
} catch {
return "";
}
}
export function getAnalyticsSessionId() {
if (!isClient()) return "";
try {
return getStoredId(window.sessionStorage, SESSION_ID_KEY);
} catch {
return "";
}
}
export function markAnalyticsOnce(key: string, scope: "local" | "session" = "session") {
if (!isClient()) return false;
const storage = scope === "local" ? window.localStorage : window.sessionStorage;
const normalizedKey = `polyweather:analytics:once:${key}`;
try {
if (storage.getItem(normalizedKey) === "1") {
return false;
}
storage.setItem(normalizedKey, "1");
return true;
} catch {
return true;
}
}
export function trackAppEvent(
eventType: TrackableAnalyticsEvent,
payload: Record<string, unknown> = {},
) {
if (!isClient() || !ANALYTICS_ENABLED) return;
const body = {
event_type: eventType,
client_id: getAnalyticsClientId() || undefined,
session_id: getAnalyticsSessionId() || undefined,
payload: {
...payload,
path: window.location.pathname,
href: window.location.href,
captured_at: new Date().toISOString(),
},
};
void fetch("/api/analytics/events", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
keepalive: true,
}).catch(() => {});
}
+14 -4
View File
@@ -9,6 +9,12 @@ export const FORWARDED_SUPABASE_EMAIL_HEADER = "x-polyweather-auth-email";
type HeaderBuildResult = {
headers: HeadersInit;
response: NextResponse | null;
authUserId?: string | null;
authEmail?: string | null;
};
type HeaderBuildOptions = {
includeSupabaseIdentity?: boolean;
};
function extractBearerToken(headerValue: string | null) {
@@ -22,6 +28,7 @@ function extractBearerToken(headerValue: string | null) {
export async function buildBackendRequestHeaders(
request: NextRequest,
options?: HeaderBuildOptions,
): Promise<HeaderBuildResult> {
const headers = new Headers({
Accept: "application/json",
@@ -32,7 +39,8 @@ export async function buildBackendRequestHeaders(
}
const incomingAuth = extractBearerToken(request.headers.get("authorization"));
if (hasSupabaseServerEnv()) {
const includeSupabaseIdentity = options?.includeSupabaseIdentity !== false;
if (hasSupabaseServerEnv() && includeSupabaseIdentity) {
const passthroughResponse = new NextResponse(null, { status: 200 });
const supabase = createSupabaseRouteClient(request, passthroughResponse);
const {
@@ -53,20 +61,20 @@ export async function buildBackendRequestHeaders(
if (incomingAuth) {
headers.set("Authorization", `Bearer ${incomingAuth}`);
return { headers, response: passthroughResponse };
return { headers, response: passthroughResponse, authUserId: forwardedUserId || null, authEmail: forwardedEmail || null };
}
const accessToken = session?.access_token || "";
if (accessToken) {
// Fallback to cookie-backed session when request does not carry bearer.
headers.set("Authorization", `Bearer ${accessToken}`);
}
return { headers, response: passthroughResponse };
return { headers, response: passthroughResponse, authUserId: forwardedUserId || null, authEmail: forwardedEmail || null };
}
if (incomingAuth) {
headers.set("Authorization", `Bearer ${incomingAuth}`);
}
return { headers, response: null };
return { headers, response: null, authUserId: null, authEmail: null };
}
export function applyAuthResponseCookies(
@@ -81,3 +89,5 @@ export function applyAuthResponseCookies(
}
return target;
}
+81 -70
View File
@@ -3,17 +3,16 @@
import {
CityDetail,
CityListItem,
MarketScan,
CitySummary,
HistoryPoint,
HistoryPayload,
} from "@/lib/dashboard-types";
const CACHE_KEY = "polyWeather_v1";
const CACHE_TTL_MS = 5 * 60 * 1000;
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
const pendingHistoryRequests = new Map<string, Promise<HistoryPoint[]>>();
const pendingHistoryRequests = new Map<string, Promise<HistoryPayload>>();
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
const pendingMarketScanRequests = new Map<string, Promise<MarketScan | null>>();
const PRIORITY_WARM_SESSION_KEY = "polyWeather_priority_warm_v1";
type CityCacheMeta = {
cachedAt: number;
@@ -29,6 +28,13 @@ function normalizeCityName(cityName: string) {
return encodeURIComponent(String(cityName).replace(/\s/g, "-"));
}
function normalizeDetailDepth(depth?: "panel" | "market" | "nearby" | "full") {
if (depth === "full") return "full";
if (depth === "nearby") return "nearby";
if (depth === "market") return "market";
return "panel";
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
headers: { Accept: "application/json" },
@@ -52,11 +58,31 @@ function normalizeRevisionPart(value: unknown) {
export function getCityRevision(source?: CityDetail | CitySummary | null) {
if (!source) return "";
const modelDaily =
"multi_model_daily" in source && source.multi_model_daily
? source.multi_model_daily?.[source.local_date || ""]
: null;
const modelFootprint = modelDaily?.models || ("multi_model" in source ? source.multi_model : null);
const forecastFootprint =
"forecast" in source && Array.isArray(source.forecast?.daily)
? source.forecast.daily
.map((item) => `${normalizeRevisionPart(item?.date)}:${normalizeRevisionPart(item?.max_temp)}`)
.join("|")
: "";
return [
normalizeRevisionPart(source.updated_at),
normalizeRevisionPart(source.current?.obs_time),
normalizeRevisionPart(source.current?.temp),
normalizeRevisionPart(source.deb?.prediction),
normalizeRevisionPart(
modelFootprint && typeof modelFootprint === "object"
? Object.keys(modelFootprint)
.sort()
.map((key) => `${key}:${normalizeRevisionPart(modelFootprint[key])}`)
.join("|")
: "",
),
normalizeRevisionPart(forecastFootprint),
].join("|");
}
@@ -74,6 +100,7 @@ export function toCitySummary(detail: CityDetail): CitySummary {
deb: {
prediction: detail.deb?.prediction,
},
deviation_monitor: detail.deviation_monitor,
risk: {
level: detail.risk?.level,
warning: detail.risk?.warning,
@@ -116,6 +143,24 @@ export const dashboardClient = {
return data.cities || [];
},
sendPriorityWarmHint(timezone?: string | null) {
if (!isClient()) return;
const tz = String(
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
).trim();
if (!tz) return;
const cacheKey = `${PRIORITY_WARM_SESSION_KEY}:${tz}`;
if (window.sessionStorage.getItem(cacheKey)) return;
window.sessionStorage.setItem(cacheKey, "1");
const params = new URLSearchParams({ timezone: tz });
void fetch(`/api/system/priority-warm?${params.toString()}`, {
method: "POST",
headers: { Accept: "application/json" },
cache: "no-store",
keepalive: true,
}).catch(() => {});
},
async getCitySummary(cityName: string, options?: { force?: boolean }) {
const force = options?.force ?? false;
const requestKey = `${cityName}::${force ? "force" : "cached"}`;
@@ -134,17 +179,21 @@ export const dashboardClient = {
return request;
},
async getCityDetail(cityName: string, options?: { force?: boolean }) {
async getCityDetail(
cityName: string,
options?: { force?: boolean; depth?: "panel" | "market" | "nearby" | "full" },
) {
const force = options?.force ?? false;
const depth = normalizeDetailDepth(options?.depth);
if (!force) {
const requestKey = `${cityName}::cached`;
const requestKey = `${cityName}::${depth}::cached`;
const existing = pendingCityDetailRequests.get(requestKey);
if (existing) {
return existing;
}
const request = fetchJson<CityDetail>(
`/api/city/${normalizeCityName(cityName)}?force_refresh=false`,
`/api/city/${normalizeCityName(cityName)}?force_refresh=false&depth=${depth}`,
).finally(() => {
pendingCityDetailRequests.delete(requestKey);
});
@@ -155,6 +204,7 @@ export const dashboardClient = {
const params = new URLSearchParams({
force_refresh: "true",
depth,
_ts: String(Date.now()),
});
return fetchJson<CityDetail>(
@@ -162,75 +212,36 @@ export const dashboardClient = {
);
},
async getCityMarketScan(
cityName: string,
options?: {
force?: boolean;
marketSlug?: string | null;
targetDate?: string | null;
},
) {
const force = options?.force ?? false;
const marketSlug = options?.marketSlug || null;
const targetDate = options?.targetDate || null;
if (!force) {
const requestKey = `${cityName}::cached::${marketSlug || "-"}::${
targetDate || "-"
}`;
const existing = pendingMarketScanRequests.get(requestKey);
if (existing) {
return existing;
}
const params = new URLSearchParams({
force_refresh: "false",
});
if (marketSlug) {
params.set("market_slug", marketSlug);
}
if (targetDate) {
params.set("target_date", targetDate);
}
const request = fetchJson<{ market_scan?: MarketScan }>(
`/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`,
)
.then((data) => data.market_scan || null)
.finally(() => {
pendingMarketScanRequests.delete(requestKey);
});
pendingMarketScanRequests.set(requestKey, request);
return request;
}
const params = new URLSearchParams({
force_refresh: "true",
_ts: String(Date.now()),
});
if (marketSlug) {
params.set("market_slug", marketSlug);
}
if (targetDate) {
params.set("target_date", targetDate);
}
return fetchJson<{ market_scan?: MarketScan }>(
`/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`,
).then((data) => data.market_scan || null);
},
async getHistory(cityName: string) {
const requestKey = normalizeCityName(cityName);
async getHistory(cityName: string, options?: { includeRecords?: boolean }) {
const includeRecords = options?.includeRecords === true;
const requestKey = `${normalizeCityName(cityName)}::${
includeRecords ? "full" : "preview"
}`;
const existing = pendingHistoryRequests.get(requestKey);
if (existing) {
return existing;
}
const request = fetchJson<{ history?: HistoryPoint[] }>(
`/api/history/${requestKey}`,
const params = new URLSearchParams();
if (includeRecords) {
params.set("include_records", "true");
}
const request = fetchJson<HistoryPayload>(
`/api/history/${normalizeCityName(cityName)}${
params.size ? `?${params.toString()}` : ""
}`,
)
.then((data) => data.history || [])
.then((data) => ({
...data,
full_count: Number(data.full_count || 0),
has_more: data.has_more === true,
history: Array.isArray(data.history) ? data.history : [],
mode: (data.mode === "full" ? "full" : "preview") as
| "full"
| "preview",
preview_count: Number(data.preview_count || 0),
}))
.finally(() => {
pendingHistoryRequests.delete(requestKey);
});
+382 -23
View File
@@ -6,6 +6,23 @@ export type OfficialSourceLink = {
kind: "agency" | "airport" | "metar";
};
function buildJmaAmedasTenMinuteUrl(
localDate?: string | null,
options?: {
blockNo?: string;
precNo?: string;
},
) {
const blockNo = String(options?.blockNo || "0371").trim() || "0371";
const precNo = String(options?.precNo || "44").trim() || "44";
const match = String(localDate || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
const now = new Date();
const year = match?.[1] || String(now.getFullYear());
const month = match?.[2] || String(now.getMonth() + 1).padStart(2, "0");
const day = match?.[3] || String(now.getDate()).padStart(2, "0");
return `https://www.data.jma.go.jp/stats/etrn/view/10min_a1.php?prec_no=${encodeURIComponent(precNo)}&block_no=${encodeURIComponent(blockNo)}&year=${encodeURIComponent(year)}&month=${encodeURIComponent(month)}&day=${encodeURIComponent(day)}&view=`;
}
const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
singapore: [
{
@@ -57,21 +74,135 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
href: "https://aviationweather.gov/data/metar/?id=VHHH&decoded=1&taf=1",
kind: "metar",
},
],
taipei: [
{
label: "NOAA RCTP Timeseries",
href: "https://www.weather.gov/wrh/timeseries?site=RCTP",
label: "流浮山站(HKO",
href: "https://www.hko.gov.hk/sc/wxinfo/ts/index.htm",
kind: "agency",
},
],
"lau fau shan": [
{
label: "香港天文台",
href: "https://www.hko.gov.hk/en/index.html",
kind: "agency",
},
{
label: "桃园机场",
href: "https://www.taoyuan-airport.com/",
label: "流浮山站(HKO",
href: "https://www.hko.gov.hk/sc/wxinfo/ts/index.htm",
kind: "airport",
},
],
taipei: [
{
label: "Wunderground RCSS",
href: "https://www.wunderground.com/history/daily/tw/taipei/RCSS",
kind: "agency",
},
{
label: "台北松山机场",
href: "https://www.tsa.gov.tw/?lang=en",
kind: "airport",
},
{
label: "RCTP METAR",
href: "https://aviationweather.gov/data/metar/?id=RCTP&decoded=1&taf=1",
label: "RCSS METAR",
href: "https://aviationweather.gov/data/metar/?id=RCSS&decoded=1&taf=1",
kind: "metar",
},
],
busan: [
{
label: "Wunderground RKPK",
href: "https://www.wunderground.com/history/daily/kr/busan/RKPK",
kind: "agency",
},
{
label: "金海国际机场",
href: "https://www.airport.co.kr/gimhaeeng/index.do",
kind: "airport",
},
{
label: "RKPK METAR",
href: "https://aviationweather.gov/data/metar/?id=RKPK&decoded=1&taf=1",
kind: "metar",
},
],
manila: [
{
label: "Wunderground RPLL",
href: "https://www.wunderground.com/history/daily/ph/manila/RPLL",
kind: "agency",
},
{
label: "Ninoy Aquino International Airport",
href: "https://www.newnaia.com.ph/",
kind: "airport",
},
{
label: "RPLL METAR",
href: "https://aviationweather.gov/data/metar/?id=RPLL&decoded=1&taf=1",
kind: "metar",
},
],
karachi: [
{
label: "Wunderground OPKC",
href: "https://www.wunderground.com/history/daily/pk/karachi/OPKC",
kind: "agency",
},
{
label: "Jinnah International Airport",
href: "https://www.caapakistan.com.pk/",
kind: "airport",
},
{
label: "OPKC METAR",
href: "https://aviationweather.gov/data/metar/?id=OPKC&decoded=1&taf=1",
kind: "metar",
},
],
"masroor air base": [
{
label: "OPMR METAR",
href: "https://aviationweather.gov/data/metar/?id=OPMR&decoded=1&taf=1",
kind: "metar",
},
],
istanbul: [
{
label: "MGM",
href: "https://www.mgm.gov.tr/",
kind: "agency",
},
{
label: "NOAA LTFM Timeseries",
href: "https://www.weather.gov/wrh/timeseries?site=LTFM",
kind: "agency",
},
{
label: "Istanbul Airport",
href: "https://www.istairport.com/en",
kind: "airport",
},
{
label: "LTFM METAR",
href: "https://aviationweather.gov/data/metar/?id=LTFM&decoded=1&taf=1",
kind: "metar",
},
],
moscow: [
{
label: "NOAA UUWW Timeseries",
href: "https://www.weather.gov/wrh/timeseries?site=UUWW",
kind: "agency",
},
{
label: "Vnukovo International Airport",
href: "https://vnukovo.ru/en/",
kind: "airport",
},
{
label: "UUWW METAR",
href: "https://metar-taf.com/UUWW",
kind: "metar",
},
],
@@ -99,6 +230,108 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
kind: "metar",
},
],
"los angeles": [
{
label: "NWS Los Angeles/Oxnard",
href: "https://www.weather.gov/lox/",
kind: "agency",
},
{
label: "LAX Airport",
href: "https://www.flylax.com/",
kind: "airport",
},
{
label: "KLAX METAR",
href: "https://aviationweather.gov/data/metar/?id=KLAX&decoded=1&taf=1",
kind: "metar",
},
],
"san francisco": [
{
label: "NWS San Francisco Bay Area",
href: "https://www.weather.gov/mtr/",
kind: "agency",
},
{
label: "SFO Airport",
href: "https://www.flysfo.com/",
kind: "airport",
},
{
label: "KSFO METAR",
href: "https://aviationweather.gov/data/metar/?id=KSFO&decoded=1&taf=1",
kind: "metar",
},
],
aurora: [
{
label: "NWS Denver/Boulder",
href: "https://www.weather.gov/bou/",
kind: "agency",
},
{
label: "Buckley Space Force Base",
href: "https://www.buckley.spaceforce.mil/",
kind: "airport",
},
{
label: "KBKF METAR",
href: "https://aviationweather.gov/data/metar/?id=KBKF&decoded=1&taf=1",
kind: "metar",
},
],
austin: [
{
label: "NWS Austin/San Antonio",
href: "https://www.weather.gov/ewx/",
kind: "agency",
},
{
label: "Austin-Bergstrom Airport",
href: "https://www.austintexas.gov/airport",
kind: "airport",
},
{
label: "KAUS METAR",
href: "https://aviationweather.gov/data/metar/?id=KAUS&decoded=1&taf=1",
kind: "metar",
},
],
houston: [
{
label: "NWS Houston/Galveston",
href: "https://www.weather.gov/hgx/",
kind: "agency",
},
{
label: "William P. Hobby Airport",
href: "https://www.fly2houston.com/hobby",
kind: "airport",
},
{
label: "KHOU METAR",
href: "https://aviationweather.gov/data/metar/?id=KHOU&decoded=1&taf=1",
kind: "metar",
},
],
"mexico city": [
{
label: "SMN",
href: "https://smn.conagua.gob.mx/",
kind: "agency",
},
{
label: "AICM",
href: "https://www.aicm.com.mx/",
kind: "airport",
},
{
label: "MMMX METAR",
href: "https://aviationweather.gov/data/metar/?id=MMMX&decoded=1&taf=1",
kind: "metar",
},
],
ankara: [
{
label: "MGM",
@@ -137,10 +370,15 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
],
shanghai: [
{
label: "中国天气",
href: "https://www.weather.com.cn/",
label: "NMC 浦东天气",
href: "https://m.nmc.cn/publish/forecast/ASH/pudong.html",
kind: "agency",
},
{
label: "上海浦东国际机场",
href: "https://www.shanghai-airport.com/",
kind: "airport",
},
{
label: "ZSPD METAR",
href: "https://aviationweather.gov/data/metar/?id=ZSPD&decoded=1&taf=1",
@@ -149,8 +387,8 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
],
tokyo: [
{
label: "JMA",
href: "https://www.jma.go.jp/jma/indexe.html",
label: "JMA 羽田10分钟实况",
href: "",
kind: "agency",
},
{
@@ -329,10 +567,15 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
],
chengdu: [
{
label: "中国天气",
href: "https://www.weather.com.cn/",
label: "NMC 双流天气",
href: "https://m.nmc.cn/publish/forecast/ASC/shuangliu.html",
kind: "agency",
},
{
label: "成都双流国际机场",
href: "https://www.cdairport.com/",
kind: "airport",
},
{
label: "ZUUU METAR",
href: "https://aviationweather.gov/data/metar/?id=ZUUU&decoded=1&taf=1",
@@ -341,10 +584,15 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
],
chongqing: [
{
label: "中国天气",
href: "https://www.weather.com.cn/",
label: "NMC 渝北天气",
href: "https://m.nmc.cn/publish/forecast/ACQ/yubei.html",
kind: "agency",
},
{
label: "重庆江北国际机场",
href: "https://www.cqa.cn/",
kind: "airport",
},
{
label: "ZUCK METAR",
href: "https://aviationweather.gov/data/metar/?id=ZUCK&decoded=1&taf=1",
@@ -353,22 +601,117 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
],
shenzhen: [
{
label: "中国天气",
href: "https://www.weather.com.cn/",
label: "NMC 深圳天气",
href: "https://m.nmc.cn/publish/forecast/AGD/shenzuo.html",
kind: "agency",
},
{
label: "深圳宝安国际机场",
href: "https://www.szairport.com/",
kind: "airport",
},
{
label: "ZGSZ METAR",
href: "https://aviationweather.gov/data/metar/?id=ZGSZ&decoded=1&taf=1",
kind: "metar",
},
],
"kuala lumpur": [
{
label: "Wunderground WMKK",
href: "https://www.wunderground.com/history/daily/my/sepang-district/WMKK",
kind: "agency",
},
{
label: "吉隆坡国际机场",
href: "https://airports.malaysiaairports.com.my/klia",
kind: "airport",
},
{
label: "WMKK METAR",
href: "https://aviationweather.gov/data/metar/?id=WMKK&decoded=1&taf=1",
kind: "metar",
},
],
jakarta: [
{
label: "Wunderground WIHH",
href: "https://www.wunderground.com/history/daily/id/jakarta/WIHH",
kind: "agency",
},
{
label: "Halim Perdanakusuma Airport",
href: "https://www.angkasapura2.co.id/en/airport/read/HLP",
kind: "airport",
},
{
label: "WIHH METAR",
href: "https://aviationweather.gov/data/metar/?id=WIHH&decoded=1&taf=1",
kind: "metar",
},
],
helsinki: [
{
label: "Wunderground EFHK",
href: "https://www.wunderground.com/history/daily/fi/vantaa/EFHK",
kind: "agency",
},
{
label: "Helsinki Airport",
href: "https://www.finavia.fi/en/airports/helsinki-airport",
kind: "airport",
},
{
label: "EFHK METAR",
href: "https://aviationweather.gov/data/metar/?id=EFHK&decoded=1&taf=1",
kind: "metar",
},
],
amsterdam: [
{
label: "Wunderground EHAM",
href: "https://www.wunderground.com/history/daily/nl/schiphol/EHAM",
kind: "agency",
},
{
label: "Amsterdam Airport Schiphol",
href: "https://www.schiphol.nl/en/",
kind: "airport",
},
{
label: "EHAM METAR",
href: "https://aviationweather.gov/data/metar/?id=EHAM&decoded=1&taf=1",
kind: "metar",
},
],
"panama city": [
{
label: "Wunderground MPMG",
href: "https://www.wunderground.com/history/daily/pa/panama-city/MPMG",
kind: "agency",
},
{
label: "Marcos A. Gelabert Airport",
href: "https://www.aeropuertos.net/aeropuerto-internacional-marcos-a-gelabert/",
kind: "airport",
},
{
label: "MPMG METAR",
href: "https://aviationweather.gov/data/metar/?id=MPMG&decoded=1&taf=1",
kind: "metar",
},
],
beijing: [
{
label: "中国天气",
href: "https://www.weather.com.cn/",
label: "NMC 顺义天气",
href: "https://m.nmc.cn/publish/forecast/ABJ/shunyi.html",
kind: "agency",
},
{
label: "北京首都国际机场",
href: "https://www.bcia.com.cn/",
kind: "airport",
},
{
label: "ZBAA METAR",
href: "https://aviationweather.gov/data/metar/?id=ZBAA&decoded=1&taf=1",
@@ -377,10 +720,15 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
],
wuhan: [
{
label: "中国天气",
href: "https://www.weather.com.cn/",
label: "NMC 武汉天气",
href: "https://m.nmc.cn/publish/forecast/AHB/wuhan.html",
kind: "agency",
},
{
label: "武汉天河国际机场",
href: "https://www.whairport.com/",
kind: "airport",
},
{
label: "ZHHH METAR",
href: "https://aviationweather.gov/data/metar/?id=ZHHH&decoded=1&taf=1",
@@ -391,7 +739,18 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
export function getOfficialSourceLinks(detail: CityDetail): OfficialSourceLink[] {
const cityKey = String(detail.name || "").trim().toLowerCase();
const links = [...(CITY_SPECIFIC_SOURCES[cityKey] || [])];
const links = [...(CITY_SPECIFIC_SOURCES[cityKey] || [])].map((link) => {
if (cityKey === "tokyo" && link.kind === "agency" && link.label === "JMA 羽田10分钟实况") {
return {
...link,
href: buildJmaAmedasTenMinuteUrl(detail.local_date, {
blockNo: "0371",
precNo: "44",
}),
};
}
return link;
});
const seen = new Set<string>();
return links.filter((link) => {
const key = `${link.label}|${link.href}`;
+235
View File
@@ -6,6 +6,11 @@ export interface CityListItem {
lat: number;
lon: number;
risk_level: RiskLevel;
deb_recent_tier?: RiskLevel;
deb_recent_hit_rate?: number | null;
deb_recent_sample_count?: number;
deb_recent_mae?: number | null;
deb_recent_last_date?: string | null;
risk_emoji?: string;
airport: string;
icao: string;
@@ -13,6 +18,10 @@ export interface CityListItem {
is_major?: boolean;
settlement_source?: string;
settlement_source_label?: string;
settlement_station_code?: string;
settlement_station_label?: string;
network_provider?: string;
network_provider_label?: string;
}
export interface ProbabilityBucket {
@@ -50,6 +59,8 @@ export interface CurrentConditions {
wu_settlement: number | null;
settlement_source?: string | null;
settlement_source_label?: string | null;
station_code?: string | null;
station_name?: string | null;
obs_time: string | null;
obs_age_min: number | null;
wind_speed_kt: number | null;
@@ -66,15 +77,48 @@ export interface CurrentConditions {
dewpoint?: number | null;
}
export interface AirportCurrentConditions {
temp: number | null;
obs_time: string | null;
max_so_far?: number | null;
max_temp_time?: string | null;
obs_age_min?: number | null;
report_time?: string | null;
receipt_time?: string | null;
obs_time_epoch?: number | null;
wind_speed_kt?: number | null;
wind_dir?: number | null;
humidity?: number | null;
cloud_desc?: string | null;
visibility_mi?: number | null;
wx_desc?: string | null;
raw_metar?: string | null;
source_label?: string | null;
station_code?: string | null;
station_label?: string | null;
is_airport_station?: boolean;
is_official?: boolean;
is_settlement_anchor?: boolean;
}
export interface NearbyStation {
name?: string;
icao?: string;
station_code?: string | null;
station_label?: string | null;
lat: number;
lon: number;
temp: number | null;
wind_dir?: number | null;
wind_speed?: number | null;
wind_speed_kt?: number | null;
source_code?: string | null;
source_label?: string | null;
is_official?: boolean;
is_airport_station?: boolean;
is_settlement_anchor?: boolean;
wind_direction_text?: string | null;
wind_power_text?: string | null;
}
export interface HourlyTrendPoint {
@@ -135,10 +179,13 @@ export interface CitySummary {
current?: {
temp?: number | null;
obs_time?: string | null;
settlement_source?: string | null;
settlement_source_label?: string | null;
};
deb?: {
prediction?: number | null;
};
deviation_monitor?: DeviationMonitor;
risk?: {
level?: RiskLevel;
warning?: string | null;
@@ -146,6 +193,19 @@ export interface CitySummary {
updated_at?: string | null;
}
export interface DeviationMonitor {
available?: boolean;
current_delta?: number | null;
reference_temp?: number | null;
direction?: "normal" | "cold" | "hot" | string;
severity?: "normal" | "light" | "strong" | string;
trend?: "stable" | "expanding" | "contracting" | string;
label_zh?: string | null;
label_en?: string | null;
trend_label_zh?: string | null;
trend_label_en?: string | null;
}
export interface HourlySeries {
times?: string[];
temps?: Array<number | null>;
@@ -153,9 +213,15 @@ export interface HourlySeries {
pressure_msl?: Array<number | null>;
wind_speed_10m?: Array<number | null>;
wind_direction_10m?: Array<number | null>;
wind_speed_180m?: Array<number | null>;
wind_direction_180m?: Array<number | null>;
precipitation_probability?: Array<number | null>;
cloud_cover?: Array<number | null>;
radiation?: Array<number | null>;
cape?: Array<number | null>;
convective_inhibition?: Array<number | null>;
lifted_index?: Array<number | null>;
boundary_layer_height?: Array<number | null>;
}
export interface WeatherGovPeriod {
@@ -196,6 +262,7 @@ export interface MarketPrimary {
id?: string | null;
question?: string | null;
slug?: string | null;
market_url?: string | null;
condition_id?: string | null;
end_date?: string | null;
active?: boolean;
@@ -223,6 +290,8 @@ export interface MarketScan {
available?: boolean;
reason?: string | null;
primary_market?: MarketPrimary | null;
market_url?: string | null;
primary_market_url?: string | null;
selected_date?: string | null;
selected_condition_id?: string | null;
selected_slug?: string | null;
@@ -247,6 +316,31 @@ export interface MarketScan {
websocket?: Record<string, unknown>;
}
export interface IntradayMeteorologySignal {
label?: string | null;
label_en?: string | null;
direction?: "support" | "suppress" | "neutral" | string | null;
strength?: "weak" | "medium" | "strong" | string | null;
summary?: string | null;
summary_en?: string | null;
}
export interface IntradayMeteorology {
headline?: string | null;
headline_en?: string | null;
confidence?: "low" | "medium" | "high" | string | null;
base_case_bucket?: string | null;
upside_bucket?: string | null;
downside_bucket?: string | null;
next_observation_time?: string | null;
peak_window?: string | null;
invalidation_rules?: string[] | null;
invalidation_rules_en?: string[] | null;
confirmation_rules?: string[] | null;
confirmation_rules_en?: string[] | null;
signal_contributions?: IntradayMeteorologySignal[] | null;
}
export interface AiAnalysisStructured {
summary?: string | null;
text?: string | null;
@@ -258,6 +352,7 @@ export interface AiAnalysisStructured {
export interface CityDetail {
name: string;
display_name: string;
detail_depth?: "panel" | "market" | "nearby" | "full";
lat: number;
lon: number;
temp_symbol: string;
@@ -265,12 +360,53 @@ export interface CityDetail {
local_date: string;
risk: DashboardRisk;
current: CurrentConditions;
settlement_station?: {
provider_code?: string | null;
settlement_source?: string | null;
settlement_station_code?: string | null;
settlement_station_label?: string | null;
airport_code?: string | null;
airport_name?: string | null;
is_airport_anchor?: boolean;
is_official_station_anchor?: boolean;
};
airport_current?: AirportCurrentConditions;
airport_primary?: AirportCurrentConditions;
airport_primary_today_obs?: Array<{
time?: string;
temp?: number | null;
}>;
mgm?: MgmData;
mgm_nearby?: NearbyStation[];
official_nearby?: NearbyStation[];
nearby_source?: string;
official_network_source?: string;
official_network_status?: {
provider_code?: string | null;
provider_label?: string | null;
available?: boolean;
mode?: string | null;
row_count?: number | null;
};
network_lead_signal?: {
available?: boolean;
delta?: number | null;
leader_station_code?: string | null;
leader_station_label?: string | null;
leader_temp?: number | null;
};
network_spread_signal?: {
available?: boolean;
spread?: number | null;
hottest_station_code?: string | null;
coolest_station_code?: string | null;
};
center_station_candidate?: NearbyStation | null;
airport_vs_network_delta?: number | null;
forecast?: ForecastData;
multi_model?: Record<string, number | null>;
deb?: DebForecast;
deviation_monitor?: DeviationMonitor;
probabilities?: {
mu?: number | null;
distribution?: ProbabilityBucket[];
@@ -297,12 +433,72 @@ export interface CityDetail {
dynamic_commentary?: {
summary?: string | null;
notes?: string[] | null;
headline_zh?: string | null;
headline_en?: string | null;
bullets_zh?: string[] | null;
bullets_en?: string[] | null;
source?: string | null;
};
taf?: {
source?: string | null;
icao?: string | null;
issue_time?: string | null;
valid_time_from?: string | null;
valid_time_to?: string | null;
raw_taf?: string | null;
signal?: {
available?: boolean;
source?: string | null;
peak_window?: string | null;
segments?: Array<{
type?: string | null;
start_local?: string | null;
end_local?: string | null;
tokens?: string[] | null;
}> | null;
markers?: Array<{
label_time?: string | null;
marker_type?: string | null;
start_local?: string | null;
end_local?: string | null;
suppression_level?: string | null;
summary_zh?: string | null;
summary_en?: string | null;
}> | null;
low_ceiling_ft?: number | null;
ceiling_cover?: string | null;
wind_regimes?: string[] | null;
wind_shift?: boolean | null;
suppression_level?: string | null;
disruption_level?: string | null;
summary_zh?: string | null;
summary_en?: string | null;
};
};
vertical_profile_signal?: {
source?: string | null;
window_start?: string | null;
window_end?: string | null;
cape_max?: number | null;
cin_min?: number | null;
lifted_index_min?: number | null;
boundary_layer_height_max?: number | null;
shear_10m_180m_max?: number | null;
suppression_risk?: string | null;
trigger_risk?: string | null;
mixing_strength?: string | null;
shear_risk?: string | null;
heating_setup?: string | null;
heating_score?: number | null;
summary_zh?: string | null;
summary_en?: string | null;
};
ai_analysis?: string | AiAnalysisStructured | null;
updated_at?: string;
multi_model_daily?: Record<string, DailyModelForecast>;
source_forecasts?: SourceForecasts;
market_scan?: MarketScan;
intraday_meteorology?: IntradayMeteorology;
}
export interface HistoryPoint {
@@ -312,6 +508,35 @@ export interface HistoryPoint {
mu?: number | null;
mgm?: number | null;
forecasts?: Record<string, number | null>;
settlement_source?: string | null;
settlement_station_code?: string | null;
settlement_station_label?: string | null;
truth_version?: string | null;
updated_by?: string | null;
truth_updated_at?: number | null;
actual_peak_time?: string | null;
deb_at_peak_minus_12h?: number | null;
deb_at_peak_minus_12h_time?: string | null;
deb_at_peak_minus_12h_error?: number | null;
}
export interface HistoryPayloadMeta {
mode: "preview" | "full";
hasMore: boolean;
fullCount: number;
previewCount: number;
settlementSource?: string | null;
settlementSourceLabel?: string | null;
}
export interface HistoryPayload {
history: HistoryPoint[];
has_more?: boolean;
full_count?: number;
preview_count?: number;
mode?: "preview" | "full";
settlement_source?: string | null;
settlement_source_label?: string | null;
}
export interface LoadingState {
@@ -320,19 +545,28 @@ export interface LoadingState {
refresh: boolean;
history: boolean;
marketScan?: boolean;
futureDeep?: boolean;
historyRecords?: boolean;
}
export interface HistoryState {
isOpen: boolean;
loading: boolean;
recordsLoading: boolean;
error: string | null;
dataByCity: Record<string, HistoryPoint[]>;
metaByCity: Record<string, HistoryPayloadMeta>;
}
export interface ProAccessState {
loading: boolean;
authenticated: boolean;
userId: string | null;
subscriptionActive: boolean;
subscriptionPlanCode: string | null;
subscriptionExpiresAt: string | null;
subscriptionTotalExpiresAt: string | null;
subscriptionQueuedDays: number;
points: number;
error: string | null;
}
@@ -348,3 +582,4 @@ export interface DashboardState {
historyState: HistoryState;
proAccess: ProAccessState;
}
File diff suppressed because it is too large Load Diff
+17 -12
View File
@@ -8,6 +8,8 @@ export const LOCALE_STORAGE_KEY = "polyweather.locale";
const MESSAGES: Record<Locale, Record<string, string>> = {
"zh-CN": {
"header.subtitle": "天气衍生品智能分析",
"header.docs": "文档",
"header.docsAria": "打开产品文档中心",
"header.info": "技术说明",
"header.infoAria": "查看系统技术说明",
"header.account": "账户",
@@ -24,12 +26,12 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"sidebar.currentTemp": "当前 {temp}",
"sidebar.peakTempAt": "峰值 {temp} @ {time}",
"sidebar.peakAt": "峰值 @ {time}",
"sidebar.group.high": "高风险",
"sidebar.group.medium": "中风险",
"sidebar.group.low": "低风险",
"sidebar.group.other": "其他",
"sidebar.group.high": "近期强势",
"sidebar.group.medium": "近期一般",
"sidebar.group.low": "近期偏弱",
"sidebar.group.other": "样本不足",
"dashboard.loading": "正在获取气象数据,请稍候...",
"dashboard.loading": "正在同步站点观测与结算站点信息,请稍候...",
"detail.closeAria": "关闭城市详情面板",
"detail.waitSelect": "等待选择城市",
@@ -52,7 +54,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"guide.title": "📎 PolyWeather 系统技术说明",
"guide.closeAria": "关闭技术说明",
"guide.footer":
"数据源以 METAR、香港天文台(HKO)、NOAA RCTP、Turkish MGM、Open-Meteo、weather.gov 为主。",
"数据源以 METAR、香港天文台(HKO)、NOAA 指定站点、Turkish MGM、Open-Meteo、weather.gov 为主。",
"history.title": "📊 历史准确率对账 - {city}",
"history.closeAria": "关闭历史对账",
@@ -169,6 +171,8 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
},
"en-US": {
"header.subtitle": "Weather Derivatives Intelligence",
"header.docs": "Docs",
"header.docsAria": "Open product documentation",
"header.info": "Tech Notes",
"header.infoAria": "Open system technical notes",
"header.account": "Account",
@@ -185,12 +189,13 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"sidebar.currentTemp": "Current {temp}",
"sidebar.peakTempAt": "Peak {temp} @ {time}",
"sidebar.peakAt": "Peak @ {time}",
"sidebar.group.high": "High Risk",
"sidebar.group.medium": "Medium Risk",
"sidebar.group.low": "Low Risk",
"sidebar.group.other": "Others",
"sidebar.group.high": "Recent Strong",
"sidebar.group.medium": "Recent Mixed",
"sidebar.group.low": "Recent Weak",
"sidebar.group.other": "Low Sample",
"dashboard.loading": "Loading weather data, please wait...",
"dashboard.loading":
"Synchronizing station observations and settlement station data...",
"detail.closeAria": "Close city detail panel",
"detail.waitSelect": "Waiting for city selection",
@@ -214,7 +219,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"guide.title": "📎 PolyWeather Technical Overview",
"guide.closeAria": "Close technical overview",
"guide.footer":
"Primary data sources are METAR, Hong Kong Observatory (HKO), NOAA RCTP, Turkish MGM, Open-Meteo, and weather.gov.",
"Primary data sources are METAR, Hong Kong Observatory (HKO), designated NOAA stations, Turkish MGM, Open-Meteo, and weather.gov.",
"history.title": "📊 Historical Reconciliation - {city}",
"history.closeAria": "Close history reconciliation",
+122
View File
@@ -0,0 +1,122 @@
import type { CityDetail } from "@/lib/dashboard-types";
import type { Locale } from "@/lib/i18n";
const CITY_TO_MARKET_SLUG: Record<string, string> = {
ankara: "ankara",
atlanta: "atlanta",
austin: "austin",
beijing: "beijing",
"buenos aires": "buenos-aires",
chengdu: "chengdu",
chicago: "chicago",
chongqing: "chongqing",
dallas: "dallas",
houston: "houston",
"hong kong": "hong-kong",
istanbul: "istanbul",
london: "london",
"los angeles": "los-angeles",
lucknow: "lucknow",
madrid: "madrid",
mexico: "mexico-city",
"mexico city": "mexico-city",
miami: "miami",
milan: "milan",
munich: "munich",
"new york": "nyc",
paris: "paris",
"san francisco": "san-francisco",
"sao paulo": "sao-paulo",
seattle: "seattle",
seoul: "seoul",
shanghai: "shanghai",
shenzhen: "shenzhen",
singapore: "singapore",
taipei: "taipei",
"tel aviv": "tel-aviv",
tokyo: "tokyo",
toronto: "toronto",
warsaw: "warsaw",
wellington: "wellington",
wuhan: "wuhan",
};
const MONTHS = [
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december",
];
function normalizeCityKey(detail?: CityDetail | null) {
return String(detail?.name || detail?.display_name || "")
.trim()
.toLowerCase();
}
function slugifyCityName(cityKey: string) {
return cityKey
.trim()
.toLowerCase()
.replace(/['.]/g, "")
.replace(/&/g, " and ")
.replace(/\s+/g, "-");
}
function normalizeDateParts(localDate?: string | null) {
const value = String(localDate || "").trim();
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const year = Number(match[1]);
const monthIndex = Number(match[2]) - 1;
const day = Number(match[3]);
if (
!Number.isFinite(year) ||
!Number.isFinite(monthIndex) ||
!Number.isFinite(day) ||
monthIndex < 0 ||
monthIndex > 11
) {
return null;
}
return {
year,
month: MONTHS[monthIndex],
day,
};
}
export function getTodayPolymarketUrl(
detail?: CityDetail | null,
locale: Locale = "en-US",
) {
const directMarketUrl = String(
detail?.market_scan?.market_url ||
detail?.market_scan?.primary_market_url ||
detail?.market_scan?.primary_market?.market_url ||
"",
).trim();
if (directMarketUrl) {
return directMarketUrl;
}
const cityKey = normalizeCityKey(detail);
const citySlug = CITY_TO_MARKET_SLUG[cityKey] || slugifyCityName(cityKey);
const dateParts = normalizeDateParts(detail?.local_date);
if (!citySlug || !dateParts) return null;
const prefix =
locale === "zh-CN"
? "https://polymarket.com/zh/event/"
: "https://polymarket.com/event/";
return `${prefix}highest-temperature-in-${citySlug}-on-${dateParts.month}-${dateParts.day}-${dateParts.year}`;
}
+29
View File
@@ -186,6 +186,7 @@ export interface ModelComparison {
ICON?: number;
GEM?: number;
JMA?: number;
LGBM?: number;
MGM?: number;
NWS?: number;
}
@@ -327,6 +328,16 @@ export interface CityDetail {
local_date: string;
temp_symbol: string;
current_temp: number | null;
settlement_station?: {
provider_code?: string | null;
settlement_source?: string | null;
settlement_station_code?: string | null;
settlement_station_label?: string | null;
airport_code?: string | null;
airport_name?: string | null;
is_airport_anchor?: boolean;
is_official_station_anchor?: boolean;
};
deb_prediction: number | null;
risk_level: string;
risk_warning: string;
@@ -339,6 +350,15 @@ export interface CityDetail {
mgm: any;
mgm_nearby: any[];
nearby_source?: string;
airport_primary?: any;
airport_primary_today_obs?: any[];
official_nearby?: any[];
official_network_source?: string;
official_network_status?: any;
network_lead_signal?: any;
network_spread_signal?: any;
center_station_candidate?: any;
airport_vs_network_delta?: number | null;
};
timeseries: {
metar_recent_obs: any[];
@@ -355,6 +375,15 @@ export interface CityDetail {
};
market_scan: MarketScan;
risk: any;
settlement_station?: any;
airport_primary?: any;
official_nearby?: any[];
official_network_source?: string;
official_network_status?: any;
network_lead_signal?: any;
network_spread_signal?: any;
center_station_candidate?: any;
airport_vs_network_delta?: number | null;
ai_analysis: string;
errors: Record<string, string>;
}
+133
View File
@@ -0,0 +1,133 @@
import { CityDetail, CityListItem } from "@/lib/dashboard-types";
export interface WeatherAuraProfile {
primary: string;
secondary: string;
tertiary: string;
intensity: number;
drift: number;
particleOpacity: number;
effect: WeatherAuraEffect;
effectIntensity: number;
}
export type WeatherAuraEffect =
| "rain"
| "snow"
| "fog"
| "storm"
| "wind"
| "cloud"
| "clear";
const RISK_AURA: Record<
string,
Omit<WeatherAuraProfile, "intensity" | "drift" | "effect" | "effectIntensity">
> =
{
high: {
primary: "#ff7c2a",
secondary: "#ffcf66",
tertiary: "#56c7ff",
particleOpacity: 0.42,
},
medium: {
primary: "#f6c453",
secondary: "#5eead4",
tertiary: "#7dd3fc",
particleOpacity: 0.34,
},
low: {
primary: "#38bdf8",
secondary: "#22d3ee",
tertiary: "#34d399",
particleOpacity: 0.28,
},
default: {
primary: "#6ee7ff",
secondary: "#7c8dff",
tertiary: "#60a5fa",
particleOpacity: 0.3,
},
};
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
export function getWeatherAuraProfile(
detail: CityDetail | null,
cities: CityListItem[],
): WeatherAuraProfile {
const dominantRisk =
String(detail?.risk?.level || "").toLowerCase() ||
String(cities.find((city) => city.risk_level)?.risk_level || "").toLowerCase() ||
"default";
const base = RISK_AURA[dominantRisk] || RISK_AURA.default;
const currentTemp = Number(detail?.current?.temp);
const validTemp = Number.isFinite(currentTemp) ? currentTemp : 18;
const intensity = clamp(0.7 + validTemp / 40, 0.72, 1.45);
const drift = clamp(0.45 + validTemp / 30, 0.55, 1.35);
const particleOpacity = clamp(base.particleOpacity + (intensity - 1) * 0.08, 0.22, 0.48);
const wxText = `${String(detail?.current?.wx_desc || "")} ${String(
detail?.current?.cloud_desc || "",
)}`.toUpperCase();
const windSpeed = Number(detail?.current?.wind_speed_kt);
const humidity = Number(detail?.current?.humidity);
let effect: WeatherAuraEffect = "clear";
if (
/(TS|VCTS|THUNDER|雷暴|LIGHTNING)/.test(wxText)
) {
effect = "storm";
} else if (/(SN|SG|GS|ICE|SLEET|雪|霰)/.test(wxText)) {
effect = "snow";
} else if (/(RA|DZ|SHRA|SHOWER|RAIN|DRIZZLE|雨)/.test(wxText)) {
effect = "rain";
} else if (/(FG|BR|HZ|FU|MIST|FOG|雾|霾)/.test(wxText)) {
effect = "fog";
} else if (
/(BKN|OVC|SCT|FEW|CLOUD|云|阴)/.test(wxText)
) {
effect = "cloud";
} else if (Number.isFinite(windSpeed) && windSpeed >= 18) {
effect = "wind";
}
const effectIntensity =
effect === "storm"
? clamp(
0.9 +
(Number.isFinite(windSpeed) ? windSpeed / 35 : 0) +
(Number.isFinite(humidity) ? humidity / 220 : 0),
0.9,
1.9,
)
: effect === "rain"
? clamp(
0.75 +
(Number.isFinite(humidity) ? humidity / 180 : 0) +
(Number.isFinite(windSpeed) ? windSpeed / 45 : 0),
0.72,
1.7,
)
: effect === "snow"
? clamp(0.8 + (Number.isFinite(windSpeed) ? windSpeed / 50 : 0), 0.78, 1.4)
: effect === "fog"
? clamp(0.78 + (Number.isFinite(humidity) ? humidity / 240 : 0), 0.75, 1.3)
: effect === "wind"
? clamp(0.72 + (Number.isFinite(windSpeed) ? windSpeed / 40 : 0), 0.72, 1.5)
: effect === "cloud"
? 0.82
: 0.72;
return {
...base,
intensity,
drift,
particleOpacity,
effect,
effectIntensity,
};
}
+36 -2
View File
@@ -31,18 +31,43 @@ function isStaticAsset(pathname: string) {
pathname.startsWith("/sitemap.xml") ||
pathname.startsWith("/icons/") ||
pathname.startsWith("/images/") ||
pathname.startsWith("/scenery/") ||
pathname.startsWith("/static/")
);
}
function isPublicPage(pathname: string) {
return (
pathname === "/" ||
pathname.startsWith("/docs") ||
pathname.startsWith("/subscription-help") ||
pathname === "/entitlement-required" ||
pathname.startsWith("/auth/login") ||
pathname.startsWith("/auth/callback")
);
}
function isPublicApi(pathname: string) {
return (
pathname === "/api/auth/me" ||
pathname === "/api/analytics/events" ||
pathname === "/api/cities" ||
pathname === "/api/vitals" ||
/^\/api\/city\/[^/]+$/i.test(pathname) ||
/^\/api\/city\/[^/]+\/summary$/i.test(pathname)
);
}
function shouldRefreshOptionalSupabaseSession(pathname: string) {
return (
pathname.startsWith("/account") ||
pathname.startsWith("/ops") ||
pathname.startsWith("/api/ops/") ||
pathname.startsWith("/api/payments/") ||
pathname === "/api/system/status"
);
}
function handleLegacyTokenGate(request: NextRequest) {
const requiredToken = process.env.POLYWEATHER_DASHBOARD_ACCESS_TOKEN?.trim();
if (!requiredToken) {
@@ -50,7 +75,7 @@ function handleLegacyTokenGate(request: NextRequest) {
}
const { pathname, searchParams } = request.nextUrl;
if (isStaticAsset(pathname) || isPublicPage(pathname)) {
if (isStaticAsset(pathname) || isPublicPage(pathname) || isPublicApi(pathname)) {
return NextResponse.next();
}
@@ -91,7 +116,7 @@ function handleLegacyTokenGate(request: NextRequest) {
async function handleSupabaseAuthGate(request: NextRequest) {
const { pathname } = request.nextUrl;
if (isPublicPage(pathname)) {
if (isPublicPage(pathname) || isPublicApi(pathname)) {
return NextResponse.next();
}
if (pathname.startsWith("/api/")) {
@@ -130,6 +155,15 @@ async function handleSupabaseAuthGate(request: NextRequest) {
}
async function handleSupabaseOptionalSession(request: NextRequest) {
const { pathname } = request.nextUrl;
if (
isPublicPage(pathname) ||
isPublicApi(pathname) ||
!shouldRefreshOptionalSupabaseSession(pathname)
) {
return NextResponse.next();
}
const response = NextResponse.next({
request: {
headers: request.headers,

Some files were not shown because too many files have changed in this diff Show More