From 7926cedc9701754c4805f372cd83ac8862213345 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Tue, 3 Mar 2026 21:27:57 +0800
Subject: [PATCH] feat: Introduce a web interface with an interactive map and
implement a Dynamic Ensemble Blending (DEB) algorithm for weather prediction.
---
docker-compose.yml | 13 +
requirements.txt | 2 +
src/analysis/deb_algorithm.py | 37 +-
web/app.py | 568 ++++++++++++++++++++
web/static/app.js | 706 +++++++++++++++++++++++++
web/static/index.html | 152 ++++++
web/static/style.css | 968 ++++++++++++++++++++++++++++++++++
7 files changed, 2441 insertions(+), 5 deletions(-)
create mode 100644 web/app.py
create mode 100644 web/static/app.js
create mode 100644 web/static/index.html
create mode 100644 web/static/style.css
diff --git a/docker-compose.yml b/docker-compose.yml
index 8fc1214c..0ad1282e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,3 +11,16 @@ services:
- ./data:/app/data # 挂载数据目录,确保历史数据持久化
- ./bot.log:/app/bot.log # 挂载日志文件
user: "${UID:-1000}:${GID:-1000}"
+
+ polyweather_web:
+ build: .
+ container_name: polyweather_web
+ restart: unless-stopped
+ command: python web/app.py
+ env_file:
+ - .env
+ volumes:
+ - ./data:/app/data
+ ports:
+ - "8000:8000"
+ user: "${UID:-1000}:${GID:-1000}"
diff --git a/requirements.txt b/requirements.txt
index 904e0af6..8d746973 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,3 +5,5 @@ python-dotenv
pytz
numpy
web3
+fastapi
+uvicorn
diff --git a/src/analysis/deb_algorithm.py b/src/analysis/deb_algorithm.py
index 747654d2..b9a82836 100644
--- a/src/analysis/deb_algorithm.py
+++ b/src/analysis/deb_algorithm.py
@@ -2,7 +2,34 @@ import os
import json
from datetime import datetime, timedelta
-import fcntl
+# Cross-platform file locking
+import sys
+if sys.platform == "win32":
+ import msvcrt
+
+ def _lock_sh(f):
+ msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
+
+ def _lock_ex(f):
+ msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
+
+ def _unlock(f):
+ try:
+ f.seek(0)
+ msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
+ except Exception:
+ pass
+else:
+ import fcntl
+
+ def _lock_sh(f):
+ fcntl.flock(f, fcntl.LOCK_SH)
+
+ def _lock_ex(f):
+ fcntl.flock(f, fcntl.LOCK_EX)
+
+ def _unlock(f):
+ fcntl.flock(f, fcntl.LOCK_UN)
# Simple memory cache to avoid blasting the disk if queried 10 times a minute
_history_cache = {}
@@ -22,9 +49,9 @@ def load_history(filepath):
with open(filepath, "r", encoding="utf-8") as f:
# We don't strictly need a lock for reading in Python if the write is atomic,
# but using one prevents reading half-written JSONs.
- fcntl.flock(f, fcntl.LOCK_SH)
+ _lock_sh(f)
data = json.load(f)
- fcntl.flock(f, fcntl.LOCK_UN)
+ _unlock(f)
_history_cache = data
_history_mtime = current_mtime
@@ -39,9 +66,9 @@ def save_history(filepath, data):
_history_cache = data
try:
with open(filepath, "w", encoding="utf-8") as f:
- fcntl.flock(f, fcntl.LOCK_EX)
+ _lock_ex(f)
json.dump(data, f, ensure_ascii=False, indent=2)
- fcntl.flock(f, fcntl.LOCK_UN)
+ _unlock(f)
_history_mtime = os.path.getmtime(filepath)
except Exception as e:
print(f"Error saving history: {e}")
diff --git a/web/app.py b/web/app.py
new file mode 100644
index 00000000..4c96b19e
--- /dev/null
+++ b/web/app.py
@@ -0,0 +1,568 @@
+"""
+PolyWeather Web Map API
+~~~~~~~~~~~~~~~~~~~~~~~
+FastAPI backend that reuses existing weather data collection and analysis modules.
+Serves a Leaflet-based interactive map frontend.
+"""
+
+import sys
+import os
+import math
+import time as _time
+from datetime import datetime, timezone, timedelta
+from typing import Dict, Any, Optional
+
+# Project root setup
+_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if _root not in sys.path:
+ sys.path.insert(0, _root)
+
+from fastapi import FastAPI, HTTPException
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse
+from loguru import logger
+
+from src.utils.config_loader import load_config
+from src.data_collection.weather_sources import WeatherDataCollector
+from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES
+from src.analysis.deb_algorithm import calculate_dynamic_weights, get_deb_accuracy
+
+# ──────────────────────────────────────────────────────────
+# Setup
+# ──────────────────────────────────────────────────────────
+app = FastAPI(title="PolyWeather Map", version="1.0")
+
+_static = os.path.join(os.path.dirname(__file__), "static")
+os.makedirs(_static, exist_ok=True)
+app.mount("/static", StaticFiles(directory=_static), name="static")
+
+_config = load_config()
+_weather = WeatherDataCollector(_config)
+
+# ──────────────────────────────────────────────────────────
+# City Registry
+# ──────────────────────────────────────────────────────────
+CITIES: Dict[str, Dict[str, Any]] = {
+ "ankara": {"lat": 39.9334, "lon": 32.8597, "f": False},
+ "london": {"lat": 51.5074, "lon": -0.1278, "f": False},
+ "paris": {"lat": 48.8566, "lon": 2.3522, "f": False},
+ "seoul": {"lat": 37.5665, "lon": 126.978, "f": False},
+ "toronto": {"lat": 43.6532, "lon": -79.3832, "f": False},
+ "buenos aires": {"lat": -34.6037, "lon": -58.3816, "f": False},
+ "wellington": {"lat": -41.2866, "lon": 174.7756, "f": False},
+ "new york": {"lat": 40.7128, "lon": -74.006, "f": True},
+ "chicago": {"lat": 41.8781, "lon": -87.6298, "f": True},
+ "dallas": {"lat": 32.7767, "lon": -96.797, "f": True},
+ "miami": {"lat": 25.7617, "lon": -80.1918, "f": True},
+ "atlanta": {"lat": 33.749, "lon": -84.388, "f": True},
+ "seattle": {"lat": 47.6062, "lon": -122.3321, "f": True},
+}
+
+ALIASES = {
+ "ank": "ankara", "lon": "london", "par": "paris",
+ "nyc": "new york", "chi": "chicago", "dal": "dallas",
+ "mia": "miami", "atl": "atlanta", "sea": "seattle",
+ "tor": "toronto", "sel": "seoul", "ba": "buenos aires",
+ "wel": "wellington",
+}
+
+# ──────────────────────────────────────────────────────────
+# Cache (5-min TTL)
+# ──────────────────────────────────────────────────────────
+_cache: Dict[str, Dict] = {}
+CACHE_TTL = 300
+
+
+def _sf(v) -> Optional[float]:
+ """Safe float conversion."""
+ if v is None:
+ return None
+ try:
+ return float(v)
+ except Exception:
+ return None
+
+
+# ──────────────────────────────────────────────────────────
+# Core Analysis (replicates bot_listener logic → JSON)
+# ──────────────────────────────────────────────────────────
+def _analyze(city: str) -> Dict[str, Any]:
+ """Fetch, analyse, and return structured weather data for one city."""
+ # Check cache
+ cached = _cache.get(city)
+ if cached and _time.time() - cached["t"] < CACHE_TTL:
+ return cached["d"]
+
+ info = CITIES[city]
+ lat, lon, is_f = info["lat"], info["lon"], info["f"]
+ sym = "°F" if is_f else "°C"
+
+ # ── 1. Fetch raw data ──
+ raw = _weather.fetch_all_sources(city, lat=lat, lon=lon)
+ om = raw.get("open-meteo", {})
+ metar = raw.get("metar", {})
+ mgm = raw.get("mgm", {})
+ ens_raw = raw.get("ensemble", {})
+ mm = raw.get("multi_model", {})
+ risk = CITY_RISK_PROFILES.get(city, {})
+
+ # ── 2. Current conditions (METAR primary) ──
+ mc = metar.get("current", {}) if metar else {}
+ cur_temp = _sf(mc.get("temp"))
+ max_so_far = _sf(mc.get("max_temp_so_far"))
+ max_temp_time = mc.get("max_temp_time")
+ wu_settle = round(max_so_far) if max_so_far is not None else None
+
+ # Observation time → local
+ obs_time_str = ""
+ metar_age_min = None
+ obs_t = metar.get("observation_time", "") if metar else ""
+ utc_offset = om.get("utc_offset", 0)
+ if obs_t and "T" in obs_t:
+ try:
+ dt = datetime.fromisoformat(obs_t.replace("Z", "+00:00"))
+ local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset)))
+ obs_time_str = local_dt.strftime("%H:%M")
+ metar_age_min = int(
+ (datetime.now(timezone.utc) - dt).total_seconds() / 60
+ )
+ except Exception:
+ obs_time_str = obs_t[:16]
+
+ # ── 3. Local time parsing ──
+ local_time_full = om.get("current", {}).get("local_time", "")
+ local_hour, local_minute = 12, 0
+ local_date_str = datetime.now().strftime("%Y-%m-%d")
+ try:
+ local_date_str = local_time_full.split(" ")[0]
+ tp = local_time_full.split(" ")[1].split(":")
+ local_hour = int(tp[0])
+ local_minute = int(tp[1]) if len(tp) > 1 else 0
+ except Exception:
+ local_hour = datetime.now().hour
+ local_minute = datetime.now().minute
+ local_time_str = f"{local_hour:02d}:{local_minute:02d}"
+ local_hour_frac = local_hour + local_minute / 60
+
+ # ── 4. Daily forecast ──
+ daily = om.get("daily", {})
+ dates = daily.get("time", [])[:5]
+ maxtemps = daily.get("temperature_2m_max", [])[:5]
+ sunrises = daily.get("sunrise", [])
+ sunsets = daily.get("sunset", [])
+ sunshine = daily.get("sunshine_duration", [])
+ om_today = _sf(maxtemps[0]) if maxtemps else None
+
+ forecast_daily = [{"date": d, "max_temp": t} for d, t in zip(dates, maxtemps)]
+ sunrise = (
+ sunrises[0].split("T")[1][:5]
+ if sunrises and "T" in str(sunrises[0])
+ else ""
+ )
+ sunset = (
+ sunsets[0].split("T")[1][:5]
+ if sunsets and "T" in str(sunsets[0])
+ else ""
+ )
+ sunshine_h = round(sunshine[0] / 3600, 1) if sunshine else 0
+
+ # ── 5. Multi-model forecasts ──
+ current_forecasts: Dict[str, float] = {}
+ if om_today is not None:
+ current_forecasts["Open-Meteo"] = om_today
+ for m, v in mm.get("forecasts", {}).items():
+ if v is not None:
+ current_forecasts[m] = _sf(v)
+ nws_high = _sf(raw.get("nws", {}).get("today_high"))
+ if nws_high is not None:
+ current_forecasts["NWS"] = nws_high
+ mb_high = _sf(raw.get("meteoblue", {}).get("today_high"))
+ if mb_high is not None:
+ current_forecasts["Meteoblue"] = mb_high
+ mgm_high = _sf(mgm.get("today_high")) if mgm else None
+ if mgm_high is not None:
+ current_forecasts["MGM"] = mgm_high
+
+ # ── 6. DEB fusion ──
+ deb_val, deb_weights = None, ""
+ if current_forecasts:
+ blended, winfo = calculate_dynamic_weights(city, current_forecasts)
+ if blended is not None:
+ deb_val = blended
+ deb_weights = winfo
+
+ # ── 7. Ensemble stats ──
+ ens_data = {
+ "median": _sf(ens_raw.get("median")),
+ "p10": _sf(ens_raw.get("p10")),
+ "p90": _sf(ens_raw.get("p90")),
+ }
+
+ # ── 8. METAR trend ──
+ recent_temps = metar.get("recent_temps", []) if metar else []
+ trend_info = {
+ "direction": "unknown",
+ "recent": [{"time": t, "temp": v} for t, v in recent_temps[:6]],
+ "is_cooling": False,
+ "is_dead_market": False,
+ }
+ if len(recent_temps) >= 2:
+ t_only = [t for _, t in recent_temps]
+ latest, prev = t_only[0], t_only[1]
+ diff = latest - prev
+ if len(t_only) >= 3:
+ n = min(3, len(t_only))
+ all_same = all(t == latest for t in t_only[:n])
+ all_rising = all(t_only[i] >= t_only[i + 1] for i in range(n - 1))
+ all_falling = all(t_only[i] <= t_only[i + 1] for i in range(n - 1))
+ if all_same:
+ trend_info["direction"] = "stagnant"
+ elif all_rising and diff > 0:
+ trend_info["direction"] = "rising"
+ elif all_falling and diff < 0:
+ trend_info["direction"] = "falling"
+ else:
+ trend_info["direction"] = "mixed"
+ elif diff > 0:
+ trend_info["direction"] = "rising"
+ elif diff < 0:
+ trend_info["direction"] = "falling"
+ else:
+ trend_info["direction"] = "stagnant"
+ trend_info["is_cooling"] = trend_info["direction"] in ("falling", "stagnant")
+
+ # ── 9. Peak hour detection ──
+ hourly = om.get("hourly", {})
+ h_times = hourly.get("time", [])
+ h_temps = hourly.get("temperature_2m", [])
+ h_rad = hourly.get("shortwave_radiation", [])
+
+ peak_hours = []
+ if h_times and h_temps and om_today is not None:
+ for ts, tmp in zip(h_times, h_temps):
+ if ts.startswith(local_date_str) and abs(tmp - om_today) <= 0.2:
+ hr = int(ts.split("T")[1][:2])
+ if 8 <= hr <= 19:
+ peak_hours.append(ts.split("T")[1][:5])
+
+ first_peak_h = int(peak_hours[0].split(":")[0]) if peak_hours else 13
+ last_peak_h = int(peak_hours[-1].split(":")[0]) if peak_hours else 15
+
+ if local_hour_frac > last_peak_h:
+ peak_status = "past"
+ elif first_peak_h <= local_hour_frac <= last_peak_h:
+ peak_status = "in_window"
+ else:
+ peak_status = "before"
+
+ # ── 10. Probability distribution ──
+ probabilities = []
+ mu = None
+ if (
+ ens_data["p10"] is not None
+ and ens_data["p90"] is not None
+ and ens_data["median"] is not None
+ ):
+ sigma = (ens_data["p90"] - ens_data["p10"]) / 2.56
+ if sigma < 0.1:
+ sigma = 0.1
+
+ # Historical MAE floor
+ acc = get_deb_accuracy(city)
+ if acc:
+ _, hist_mae, _, _ = acc
+ if hist_mae > sigma:
+ sigma = hist_mae
+
+ # Shock score
+ recent_obs = metar.get("recent_obs", []) if metar else []
+ shock = 0.0
+ if len(recent_obs) >= 2:
+ o_obs, n_obs = recent_obs[-1], recent_obs[0]
+ wd_o, wd_n = _sf(o_obs.get("wdir")), _sf(n_obs.get("wdir"))
+ ws_n = _sf(n_obs.get("wspd")) or 0
+ if wd_o is not None and wd_n is not None:
+ ad = abs(wd_n - wd_o)
+ if ad > 180:
+ ad = 360 - ad
+ shock += min(ad / 90, 1) * min(ws_n / 15, 1) * 0.4
+ cr_o = o_obs.get("cloud_rank", 0)
+ cr_n = n_obs.get("cloud_rank", 0)
+ shock += min(abs(cr_n - cr_o) / 3, 1) * 0.35
+ ap_o, ap_n = _sf(o_obs.get("altim")), _sf(n_obs.get("altim"))
+ if ap_o is not None and ap_n is not None:
+ shock += min(abs(ap_n - ap_o) / 4, 1) * 0.25
+ if shock > 0.05:
+ sigma *= 1 + 0.5 * shock
+
+ # Time-based sigma adjustment
+ if local_hour_frac > last_peak_h:
+ sigma *= 0.3
+ elif first_peak_h <= local_hour_frac <= last_peak_h:
+ sigma *= 0.7
+
+ # Mu calculation
+ forecast_highs = [h for h in current_forecasts.values() if h is not None]
+ forecast_median = (
+ sorted(forecast_highs)[len(forecast_highs) // 2]
+ if forecast_highs
+ else ens_data["median"]
+ )
+ mu = (
+ forecast_median * 0.7 + ens_data["median"] * 0.3
+ if forecast_median is not None
+ else ens_data["median"]
+ )
+ if max_so_far is not None and max_so_far > mu:
+ mu = max_so_far + (0.3 if not trend_info["is_cooling"] else 0.0)
+
+ def _norm_cdf(x, m, s):
+ return 0.5 * (1 + math.erf((x - m) / (s * math.sqrt(2))))
+
+ min_wu = round(max_so_far) if max_so_far is not None else -999
+ probs = {}
+ for n in range(round(mu) - 2, round(mu) + 3):
+ if n < min_wu:
+ continue
+ p = _norm_cdf(n + 0.5, mu, sigma) - _norm_cdf(n - 0.5, mu, sigma)
+ if p > 0.01:
+ probs[n] = p
+ total = sum(probs.values())
+ if total > 0:
+ probs = {k: v / total for k, v in probs.items()}
+ for t, p in sorted(probs.items(), key=lambda x: x[1], reverse=True)[:4]:
+ probabilities.append(
+ {"value": t, "range": f"[{t-0.5}~{t+0.5})", "probability": round(p, 3)}
+ )
+
+ # ── 11. Dead market detection ──
+ is_dead = False
+ if max_so_far is not None and cur_temp is not None:
+ if local_hour >= 21 and max_so_far - cur_temp >= 3.0:
+ is_dead = True
+ elif local_hour > last_peak_h and max_so_far - cur_temp >= 1.5:
+ is_dead = True
+ trend_info["is_dead_market"] = is_dead
+
+ # ── 12. Hourly data (today only, for chart) ──
+ today_hourly: Dict[str, list] = {"times": [], "temps": [], "radiation": []}
+ for i, ts in enumerate(h_times):
+ if ts.startswith(local_date_str):
+ today_hourly["times"].append(ts.split("T")[1][:5])
+ today_hourly["temps"].append(h_temps[i] if i < len(h_temps) else None)
+ today_hourly["radiation"].append(h_rad[i] if i < len(h_rad) else None)
+
+ # ── 13. Cloud description ──
+ clouds = mc.get("clouds", [])
+ cloud_desc = ""
+ if clouds:
+ c_map = {
+ "BKN": "多云",
+ "OVC": "阴天",
+ "FEW": "少云",
+ "SCT": "散云",
+ "SKC": "晴",
+ "CLR": "晴",
+ }
+ main = clouds[-1]
+ cloud_desc = c_map.get(main.get("cover"), main.get("cover", ""))
+
+ # ── 14. MGM data (Ankara-specific) ──
+ mgm_data = {}
+ if mgm:
+ mgc = mgm.get("current", {})
+ mgm_data = {
+ "feels_like": _sf(mgc.get("feels_like")),
+ "humidity": _sf(mgc.get("humidity")),
+ "wind_dir": _sf(mgc.get("wind_dir")),
+ "wind_speed_ms": _sf(mgc.get("wind_speed_ms")),
+ "pressure": _sf(mgc.get("pressure")),
+ "cloud_cover": mgc.get("cloud_cover"),
+ "rain_24h": _sf(mgc.get("rain_24h")),
+ }
+
+ # ── 15. AI Analysis ──
+ ai_text = ""
+ try:
+ from src.analysis.ai_analyzer import get_ai_analysis
+
+ ai_parts = []
+ if deb_val is not None:
+ ai_parts.append(f"🧬 DEB融合预测: {deb_val}{sym}")
+ if ens_data["median"] is not None:
+ ai_parts.append(
+ f"📊 集合预报: 中位数 {ens_data['median']}{sym}, "
+ f"90%区间 [{ens_data['p10']}{sym} - {ens_data['p90']}{sym}]"
+ )
+ if cur_temp is not None:
+ ai_parts.append(f"🌡️ 当前实测温度: {cur_temp}{sym}")
+ if max_so_far is not None:
+ ai_parts.append(
+ f"🏔️ 今日实测最高温: {max_so_far}{sym} (WU结算={wu_settle}{sym})"
+ )
+ if trend_info["recent"]:
+ ts_str = " → ".join(
+ [f"{r['temp']}{sym}@{r['time']}" for r in trend_info["recent"][:3]]
+ )
+ ai_parts.append(f"📈 METAR趋势: {ts_str}")
+ if probabilities:
+ prob_str = " | ".join(
+ [
+ f"{p['value']}{sym} {p['range']} {int(p['probability']*100)}%"
+ for p in probabilities
+ ]
+ )
+ ai_parts.append(f"🎲 数学概率分布:{prob_str}")
+
+ window = (
+ f"{peak_hours[0]} - {peak_hours[-1]}"
+ if len(peak_hours) > 1
+ else (peak_hours[0] if peak_hours else "13:00 - 15:00")
+ )
+ if peak_status == "past":
+ ai_parts.append(f"⏱️ 状态: 预报峰值时段已过 ({window})。")
+ elif peak_status == "in_window":
+ remain_w = last_peak_h - local_hour_frac
+ ai_parts.append(
+ f"⏱️ 状态: 正处于预报最热窗口 ({window})内,距窗口结束约 {int(remain_w*60)} 分钟。"
+ )
+ else:
+ remain = first_peak_h - local_hour_frac
+ if remain < 1:
+ ai_parts.append(
+ f"⏱️ 状态: 距最热时段开始还有约 {int(remain*60)} 分钟 ({window}),尚未进入峰值窗口。"
+ )
+ else:
+ ai_parts.append(
+ f"⏱️ 状态: 距最热时段开始还有约 {remain:.1f}h ({window})。"
+ )
+
+ wind_speed = _sf(mc.get("wind_speed_kt"))
+ wind_dir = _sf(mc.get("wind_dir"))
+ if wind_speed:
+ ai_parts.append(f"🌬️ 风况: 约 {wind_speed}kt (方向 {wind_dir or '未知'}°)。")
+ if cloud_desc:
+ ai_parts.append(f"☁️ 天空: {cloud_desc}。")
+ if current_forecasts:
+ mm_str = " | ".join(
+ [f"{k}:{v}{sym}" for k, v in current_forecasts.items() if v]
+ )
+ ai_parts.append(f"模型分歧: {mm_str}")
+
+ ai_context = "\n".join(ai_parts)
+ ai_text = get_ai_analysis(ai_context, city, sym)
+ except Exception as e:
+ logger.warning(f"AI analysis skipped for {city}: {e}")
+
+ # ── Assemble result ──
+ result = {
+ "name": city,
+ "display_name": city.title(),
+ "lat": lat,
+ "lon": lon,
+ "temp_symbol": sym,
+ "local_time": local_time_str,
+ "local_date": local_date_str,
+ "risk": {
+ "level": risk.get("risk_level", "low"),
+ "emoji": risk.get("risk_emoji", "🟢"),
+ "airport": risk.get("airport_name", ""),
+ "icao": risk.get("icao", ""),
+ "distance_km": risk.get("distance_km", 0),
+ "warning": risk.get("warning", ""),
+ },
+ "current": {
+ "temp": cur_temp,
+ "max_so_far": max_so_far,
+ "max_temp_time": max_temp_time,
+ "wu_settlement": wu_settle,
+ "obs_time": obs_time_str,
+ "obs_age_min": metar_age_min,
+ "wind_speed_kt": _sf(mc.get("wind_speed_kt")),
+ "wind_dir": _sf(mc.get("wind_dir")),
+ "humidity": _sf(mc.get("humidity")),
+ "cloud_desc": cloud_desc,
+ "clouds_raw": [
+ {"cover": c.get("cover"), "base": c.get("base")} for c in clouds
+ ],
+ "visibility_mi": _sf(mc.get("visibility_mi")),
+ "wx_desc": mc.get("wx_desc"),
+ },
+ "mgm": mgm_data,
+ "forecast": {
+ "today_high": om_today,
+ "daily": forecast_daily,
+ "sunrise": sunrise,
+ "sunset": sunset,
+ "sunshine_hours": sunshine_h,
+ },
+ "multi_model": {k: v for k, v in current_forecasts.items() if v is not None},
+ "deb": {"prediction": deb_val, "weights_info": deb_weights},
+ "ensemble": ens_data,
+ "probabilities": {
+ "mu": round(mu, 1) if mu else None,
+ "distribution": probabilities,
+ },
+ "trend": trend_info,
+ "peak": {
+ "hours": peak_hours,
+ "first_h": first_peak_h,
+ "last_h": last_peak_h,
+ "status": peak_status,
+ },
+ "hourly": today_hourly,
+ "ai_analysis": ai_text,
+ "updated_at": datetime.now(timezone.utc).isoformat(),
+ }
+
+ _cache[city] = {"t": _time.time(), "d": result}
+ return result
+
+
+# ──────────────────────────────────────────────────────────
+# Routes
+# ──────────────────────────────────────────────────────────
+@app.get("/")
+async def index():
+ return FileResponse(os.path.join(_static, "index.html"))
+
+
+@app.get("/api/cities")
+async def list_cities():
+ """Return all supported cities with coordinates and risk level."""
+ out = []
+ for name, info in CITIES.items():
+ risk = CITY_RISK_PROFILES.get(name, {})
+ out.append(
+ {
+ "name": name,
+ "display_name": name.title(),
+ "lat": info["lat"],
+ "lon": info["lon"],
+ "risk_level": risk.get("risk_level", "low"),
+ "risk_emoji": risk.get("risk_emoji", "🟢"),
+ "airport": risk.get("airport_name", ""),
+ "icao": risk.get("icao", ""),
+ "temp_unit": "fahrenheit" if info["f"] else "celsius",
+ }
+ )
+ return {"cities": out}
+
+
+@app.get("/api/city/{name}")
+async def city_detail(name: str):
+ """Return full weather analysis for a single city."""
+ name = name.lower().strip().replace("-", " ")
+ name = ALIASES.get(name, name)
+ if name not in CITIES:
+ raise HTTPException(404, detail=f"Unknown city: {name}")
+ return _analyze(name)
+
+
+# ──────────────────────────────────────────────────────────
+# Entrypoint
+# ──────────────────────────────────────────────────────────
+if __name__ == "__main__":
+ import uvicorn
+
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/web/static/app.js b/web/static/app.js
new file mode 100644
index 00000000..ce6268e9
--- /dev/null
+++ b/web/static/app.js
@@ -0,0 +1,706 @@
+/**
+ * PolyWeather 地图 — 前端应用
+ * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ * Leaflet 地图 + 详情面板 + Chart.js 温度走势
+ */
+
+// ──────────────────────────────────────────────────────────
+// State
+// ──────────────────────────────────────────────────────────
+let map = null;
+let markers = {}; // cityName → Leaflet marker
+let cityDataCache = {}; // cityName → API response
+let selectedCity = null;
+let tempChart = null;
+const AUTO_REFRESH_MS = 5 * 60 * 1000; // 5 minutes
+
+// ──────────────────────────────────────────────────────────
+// Map Setup
+// ──────────────────────────────────────────────────────────
+function initMap() {
+ map = L.map("map", {
+ center: [30, 10],
+ zoom: 3,
+ minZoom: 2,
+ maxZoom: 12,
+ zoomControl: true,
+ attributionControl: true,
+ });
+
+ // CartoDB Dark Matter tiles (free, dark theme)
+ L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
+ attribution:
+ '© OSM © CARTO',
+ subdomains: "abcd",
+ maxZoom: 19,
+ }).addTo(map);
+}
+
+// ──────────────────────────────────────────────────────────
+// Markers
+// ──────────────────────────────────────────────────────────
+function createMarkerIcon(city) {
+ const riskClass = `risk-${city.risk_level}`;
+ const label = city.display_name;
+ // Short name for marker
+ const shortName = label.length > 10 ? label.substring(0, 8) + "…" : label;
+ const tempText = city._temp !== undefined ? `${city._temp}` : "—";
+
+ const html = `
+
+
${tempText}
+
${shortName}
+
+ `;
+ return L.divIcon({
+ html: html,
+ className: "",
+ iconSize: [60, 40],
+ iconAnchor: [30, 40],
+ });
+}
+
+function addCityMarkers(cities) {
+ cities.forEach((city) => {
+ const icon = createMarkerIcon(city);
+ const marker = L.marker([city.lat, city.lon], { icon })
+ .addTo(map)
+ .on("click", () => loadCityDetail(city.name));
+
+ markers[city.name] = { marker, city };
+ });
+
+ document.getElementById("cityCount").textContent = cities.length;
+}
+
+function updateMarkerTemp(cityName, temp) {
+ const entry = markers[cityName];
+ if (!entry) return;
+ entry.city._temp = temp;
+ entry.marker.setIcon(createMarkerIcon(entry.city));
+}
+
+function setSelectedMarker(cityName) {
+ // Remove previous selection
+ Object.values(markers).forEach(({ marker }) => {
+ const el = marker.getElement();
+ if (el) el.querySelector(".city-marker")?.classList.remove("selected");
+ });
+ // Add selection
+ const entry = markers[cityName];
+ if (entry) {
+ const el = entry.marker.getElement();
+ if (el) el.querySelector(".city-marker")?.classList.add("selected");
+ }
+}
+
+// ──────────────────────────────────────────────────────────
+// City List Sidebar
+// ──────────────────────────────────────────────────────────
+function buildCityList(cities) {
+ const container = document.getElementById("cityListItems");
+ container.innerHTML = "";
+
+ // Sort: high risk first, then medium, then low
+ const order = { high: 0, medium: 1, low: 2 };
+ const sorted = [...cities].sort(
+ (a, b) => (order[a.risk_level] ?? 3) - (order[b.risk_level] ?? 3),
+ );
+
+ sorted.forEach((city) => {
+ const div = document.createElement("div");
+ div.className = "city-item";
+ div.id = `city-item-${city.name.replace(/\s/g, "-")}`;
+ div.innerHTML = `
+
+ ${city.display_name}
+ —
+ `;
+ div.addEventListener("click", () => {
+ loadCityDetail(city.name);
+ map.flyTo([city.lat, city.lon], 6, { duration: 1 });
+ });
+ container.appendChild(div);
+ });
+}
+
+function setActiveCityItem(cityName) {
+ document
+ .querySelectorAll(".city-item")
+ .forEach((el) => el.classList.remove("active"));
+ const id = `city-item-${cityName.replace(/\s/g, "-")}`;
+ const el = document.getElementById(id);
+ if (el) {
+ el.classList.add("active");
+ el.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+}
+
+function updateCityListTemp(cityName, temp, symbol) {
+ const id = `temp-${cityName.replace(/\s/g, "-")}`;
+ const el = document.getElementById(id);
+ if (el) {
+ el.textContent = `${temp}${symbol}`;
+ el.classList.add("loaded");
+ }
+}
+
+// ──────────────────────────────────────────────────────────
+// API Calls
+// ──────────────────────────────────────────────────────────
+async function fetchCities() {
+ try {
+ const res = await fetch("/api/cities");
+ const data = await res.json();
+ return data.cities || [];
+ } catch (e) {
+ console.error("Failed to fetch cities:", e);
+ return [];
+ }
+}
+
+async function fetchCityDetail(cityName) {
+ const urlName = cityName.replace(/\s/g, "-");
+ const res = await fetch(`/api/city/${encodeURIComponent(urlName)}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return await res.json();
+}
+
+// ──────────────────────────────────────────────────────────
+// Load & Render City Detail
+// ──────────────────────────────────────────────────────────
+async function loadCityDetail(cityName) {
+ selectedCity = cityName;
+ setActiveCityItem(cityName);
+ setSelectedMarker(cityName);
+ showLoading(true);
+
+ try {
+ const data = await fetchCityDetail(cityName);
+ cityDataCache[cityName] = data;
+ renderPanel(data);
+ // Update marker temperature
+ if (data.current?.temp != null) {
+ updateMarkerTemp(cityName, data.current.temp);
+ updateCityListTemp(cityName, data.current.temp, data.temp_symbol);
+ }
+ } catch (e) {
+ console.error(`Failed to load ${cityName}:`, e);
+ alert(`加载 ${cityName} 数据失败:${e.message}`);
+ } finally {
+ showLoading(false);
+ }
+}
+
+function showLoading(show) {
+ document.getElementById("loading").classList.toggle("hidden", !show);
+}
+
+// ──────────────────────────────────────────────────────────
+// Panel Rendering
+// ──────────────────────────────────────────────────────────
+function renderPanel(data) {
+ const panel = document.getElementById("panel");
+ panel.classList.remove("hidden");
+ // Trigger reflow for animation
+ requestAnimationFrame(() => panel.classList.add("visible"));
+
+ // Header
+ document.getElementById("panelCityName").textContent =
+ `${data.risk?.emoji || "🏙️"} ${data.display_name}`;
+ document.getElementById("panelLocalTime").textContent =
+ `🕐 ${data.local_time || "—"} 当地时间`;
+
+ const badge = document.getElementById("panelRiskBadge");
+ badge.textContent =
+ {
+ high: "🔴 高危",
+ medium: "🟡 中危",
+ low: "🟢 低危",
+ }[data.risk?.level] || "未知";
+ badge.className = `risk-badge ${data.risk?.level || "low"}`;
+
+ // Hero
+ renderHero(data);
+ // Chart
+ renderChart(data);
+ // Probabilities
+ renderProbabilities(data);
+ // Multi-model
+ renderModels(data);
+ // Forecast
+ renderForecast(data);
+ // AI
+ renderAI(data);
+ // Risk
+ renderRisk(data);
+}
+
+function renderHero(data) {
+ const cur = data.current || {};
+ const sym = data.temp_symbol || "°C";
+
+ document.getElementById("heroTemp").textContent =
+ cur.temp != null ? cur.temp.toFixed(1) : "—";
+ document.getElementById("heroUnit").textContent = sym;
+
+ document.getElementById("heroMax").textContent =
+ cur.max_so_far != null
+ ? `${cur.max_so_far}${sym} @${cur.max_temp_time || "—"}`
+ : "—";
+ document.getElementById("heroWU").textContent =
+ cur.wu_settlement != null ? `${cur.wu_settlement}${sym}` : "—";
+ document.getElementById("heroDEB").textContent =
+ data.deb?.prediction != null ? `${data.deb.prediction}${sym}` : "—";
+
+ // Sub info
+ const parts = [];
+ if (cur.obs_time) {
+ let ageStr = "";
+ if (cur.obs_age_min != null && cur.obs_age_min >= 30) {
+ ageStr = ` (${cur.obs_age_min}分钟前)`;
+ }
+ parts.push(`✈️ METAR ${cur.obs_time}${ageStr}`);
+ }
+ if (cur.cloud_desc) parts.push(`☁️ ${cur.cloud_desc}`);
+ if (cur.wind_speed_kt != null) {
+ parts.push(`💨 ${cur.wind_speed_kt}kt`);
+ }
+ if (cur.visibility_mi != null) {
+ parts.push(`👁️ ${cur.visibility_mi}mi`);
+ }
+
+ // Trend badge
+ const trend = data.trend || {};
+ if (trend.is_dead_market) {
+ parts.push('☠️ 死盘');
+ } else if (trend.direction && trend.direction !== "unknown") {
+ const labels = {
+ rising: "📈 升温中",
+ falling: "📉 降温中",
+ stagnant: "⏸️ 已停滞",
+ mixed: "📊 波动中",
+ };
+ parts.push(
+ `${labels[trend.direction] || trend.direction}`,
+ );
+ }
+
+ document.getElementById("heroSub").innerHTML = parts.join("");
+}
+
+function renderChart(data) {
+ const hourly = data.hourly || {};
+ const times = hourly.times || [];
+ const temps = hourly.temps || [];
+
+ if (times.length === 0) {
+ document.getElementById("chartLegend").textContent = "暂无小时数据";
+ return;
+ }
+
+ // Find current hour index
+ const curHour = data.local_time
+ ? data.local_time.split(":")[0] + ":00"
+ : null;
+ const curIdx = curHour ? times.indexOf(curHour) : -1;
+
+ // Forecast vs actual split
+ const actualTemps = temps.map((t, i) =>
+ curIdx >= 0 && i <= curIdx ? t : null,
+ );
+ const forecastTemps = temps.map((t, i) =>
+ curIdx < 0 || i >= curIdx ? t : null,
+ );
+
+ const ctx = document.getElementById("tempChart").getContext("2d");
+ if (tempChart) tempChart.destroy();
+
+ const validTemps = temps.filter((t) => t != null);
+ const minTemp = Math.floor(Math.min(...validTemps)) - 1;
+ const maxTemp = Math.ceil(Math.max(...validTemps)) + 1;
+
+ tempChart = new Chart(ctx, {
+ type: "line",
+ data: {
+ labels: times,
+ datasets: [
+ {
+ label: "实测",
+ data: actualTemps,
+ borderColor: "#22d3ee",
+ backgroundColor: "rgba(34, 211, 238, 0.1)",
+ borderWidth: 2.5,
+ pointRadius: 0,
+ pointHoverRadius: 4,
+ fill: true,
+ tension: 0.3,
+ spanGaps: false,
+ },
+ {
+ label: "预报",
+ data: forecastTemps,
+ borderColor: "rgba(99, 102, 241, 0.6)",
+ borderWidth: 1.5,
+ borderDash: [5, 3],
+ pointRadius: 0,
+ fill: false,
+ tension: 0.3,
+ spanGaps: false,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: { intersect: false, mode: "index" },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ backgroundColor: "rgba(15, 23, 42, 0.9)",
+ borderColor: "rgba(99, 102, 241, 0.3)",
+ borderWidth: 1,
+ titleFont: { family: "Inter", size: 12 },
+ bodyFont: { family: "Inter", size: 12 },
+ callbacks: {
+ label: (ctx) =>
+ `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${data.temp_symbol}`,
+ },
+ },
+ },
+ scales: {
+ x: {
+ grid: { color: "rgba(255,255,255,0.04)" },
+ ticks: {
+ color: "#64748b",
+ font: { size: 10, family: "Inter" },
+ maxRotation: 0,
+ callback: (val, idx) => (idx % 3 === 0 ? times[idx] : ""),
+ },
+ },
+ y: {
+ min: minTemp,
+ max: maxTemp,
+ grid: { color: "rgba(255,255,255,0.04)" },
+ ticks: {
+ color: "#64748b",
+ font: { size: 10, family: "Inter" },
+ callback: (v) => v + (data.temp_symbol || "°"),
+ },
+ },
+ },
+ },
+ });
+
+ // DEB prediction line annotation
+ if (data.deb?.prediction != null) {
+ const debY = data.deb.prediction;
+ tempChart.options.plugins.annotation = {
+ annotations: {
+ debLine: {
+ type: "line",
+ yMin: debY,
+ yMax: debY,
+ borderColor: "#34d399",
+ borderWidth: 1,
+ borderDash: [4, 4],
+ },
+ },
+ };
+ tempChart.update();
+ }
+
+ // METAR recent points overlay
+ const legend = document.getElementById("chartLegend");
+ if (data.trend?.recent?.length) {
+ const recentStr = data.trend.recent
+ .slice(0, 4)
+ .map((r) => `${r.temp}${data.temp_symbol}@${r.time}`)
+ .join(" → ");
+ legend.textContent = `METAR 趋势:${recentStr}`;
+ } else {
+ legend.textContent = "";
+ }
+}
+
+function renderProbabilities(data) {
+ const container = document.getElementById("probBars");
+ const probs = data.probabilities?.distribution || [];
+ const mu = data.probabilities?.mu;
+
+ if (probs.length === 0) {
+ container.innerHTML =
+ '暂无概率数据
';
+ return;
+ }
+
+ let html = "";
+ if (mu != null) {
+ html += `期望值 μ = ${mu}${data.temp_symbol}
`;
+ }
+
+ probs.forEach((p, i) => {
+ const pct = Math.round(p.probability * 100);
+ const width = Math.max(pct, 8);
+ html += `
+
+
${p.value}${data.temp_symbol}
+
+
+ `;
+ });
+ container.innerHTML = html;
+
+ // Animate bars
+ requestAnimationFrame(() => {
+ container.querySelectorAll(".prob-bar-fill").forEach((bar, i) => {
+ const pct = Math.round(probs[i].probability * 100);
+ bar.style.width = Math.max(pct, 8) + "%";
+ });
+ });
+}
+
+function renderModels(data) {
+ const container = document.getElementById("modelBars");
+ const models = data.multi_model || {};
+ const deb = data.deb?.prediction;
+
+ if (Object.keys(models).length === 0) {
+ container.innerHTML =
+ '暂无多模型数据
';
+ return;
+ }
+
+ const values = Object.values(models).filter((v) => v != null);
+ const minVal = Math.min(...values) - 1;
+ const maxVal = Math.max(...values) + 1;
+ const range = maxVal - minVal;
+
+ let html = "";
+ const sorted = Object.entries(models).sort(
+ (a, b) => (b[1] || 0) - (a[1] || 0),
+ );
+
+ sorted.forEach(([name, val]) => {
+ if (val == null) return;
+ const pct = ((val - minVal) / range) * 100;
+ const shortName = name.length > 10 ? name.substring(0, 9) + "…" : name;
+ html += `
+
+
${shortName}
+
+
${val}${data.temp_symbol}
+ ${deb != null ? `
` : ""}
+
+
+ `;
+ });
+
+ // DEB row
+ if (deb != null) {
+ const pct = ((deb - minVal) / range) * 100;
+ html += `
+
+
DEB
+
+
${deb}${data.temp_symbol}
+
+
+ `;
+ }
+
+ container.innerHTML = html;
+}
+
+function renderForecast(data) {
+ const container = document.getElementById("forecastTable");
+ const daily = data.forecast?.daily || [];
+ const sym = data.temp_symbol || "°C";
+
+ if (daily.length === 0) {
+ container.innerHTML =
+ '暂无预报
';
+ return;
+ }
+
+ let html = "";
+ daily.forEach((d, i) => {
+ const isToday = i === 0;
+ const dateLabel = isToday ? "今天" : d.date.substring(5);
+ html += `
+
+
${dateLabel}
+
${d.max_temp}${sym}
+
+ `;
+ });
+ container.innerHTML = html;
+
+ // Sun info
+ const sunEl = document.getElementById("sunInfo");
+ const parts = [];
+ if (data.forecast?.sunrise) parts.push(`🌅 ${data.forecast.sunrise}`);
+ if (data.forecast?.sunset) parts.push(`🌇 ${data.forecast.sunset}`);
+ if (data.forecast?.sunshine_hours)
+ parts.push(`☀️ ${data.forecast.sunshine_hours}h`);
+ sunEl.innerHTML = parts.map((p) => `${p}`).join("");
+}
+
+function renderAI(data) {
+ const container = document.getElementById("aiAnalysis");
+ const text = data.ai_analysis || "";
+
+ if (!text) {
+ container.innerHTML = 'AI 分析暂不可用';
+ return;
+ }
+
+ // The AI output may contain HTML tags like
+ container.innerHTML = text;
+}
+
+function renderRisk(data) {
+ const container = document.getElementById("riskInfo");
+ const risk = data.risk || {};
+
+ if (!risk.airport) {
+ container.innerHTML =
+ '无风险档案';
+ return;
+ }
+
+ container.innerHTML = `
+ 📍 机场${risk.airport} (${risk.icao})
+ 📏 距离${risk.distance_km}km
+ ${risk.warning ? `⚠️ 注意${risk.warning}
` : ""}
+ `;
+}
+
+// ──────────────────────────────────────────────────────────
+// Panel Controls
+// ──────────────────────────────────────────────────────────
+function closePanel() {
+ const panel = document.getElementById("panel");
+ panel.classList.remove("visible");
+ setTimeout(() => panel.classList.add("hidden"), 400);
+ selectedCity = null;
+ setSelectedMarker(null);
+ document
+ .querySelectorAll(".city-item")
+ .forEach((el) => el.classList.remove("active"));
+}
+
+// ──────────────────────────────────────────────────────────
+// Auto-Refresh
+// ──────────────────────────────────────────────────────────
+function startAutoRefresh() {
+ setInterval(async () => {
+ if (selectedCity) {
+ // Invalidate cache
+ delete cityDataCache[selectedCity];
+ try {
+ const data = await fetchCityDetail(selectedCity);
+ cityDataCache[selectedCity] = data;
+ renderPanel(data);
+ if (data.current?.temp != null) {
+ updateMarkerTemp(selectedCity, data.current.temp);
+ updateCityListTemp(selectedCity, data.current.temp, data.temp_symbol);
+ }
+ flashLiveBadge();
+ } catch (e) {
+ console.warn("Auto-refresh failed:", e);
+ }
+ }
+ }, AUTO_REFRESH_MS);
+}
+
+function flashLiveBadge() {
+ const badge = document.getElementById("liveBadge");
+ badge.style.transform = "scale(1.1)";
+ setTimeout(() => {
+ badge.style.transform = "scale(1)";
+ }, 300);
+}
+
+// ──────────────────────────────────────────────────────────
+// Background Progressive Loading
+// ──────────────────────────────────────────────────────────
+async function loadAllCitiesProgressively(cities) {
+ // 延迟 1 秒后开始后台加载,避免阻塞初始渲染
+ await new Promise((r) => setTimeout(r, 1000));
+
+ for (const city of cities) {
+ // Skip if already clicked/loaded or selected
+ if (!cityDataCache[city.name]) {
+ try {
+ const urlName = city.name.replace(/\s/g, "-");
+ const res = await fetch(`/api/city/${encodeURIComponent(urlName)}`);
+ if (res.ok) {
+ const data = await res.json();
+ cityDataCache[city.name] = data;
+
+ // 如果用户目前没有点击它,仅更新标记和列表
+ if (data.current?.temp != null) {
+ updateMarkerTemp(city.name, data.current.temp);
+ updateCityListTemp(city.name, data.current.temp, data.temp_symbol);
+ }
+
+ // 如果恰好在这个时候用户选中了这个城市,顺便刷新面板
+ if (selectedCity === city.name) {
+ renderPanel(data);
+ }
+ }
+ } catch (e) {
+ console.warn(`Background load failed for ${city.name}`, e);
+ }
+ // 间隔 800ms,避免瞬间并发轰炸后端 API
+ await new Promise((r) => setTimeout(r, 800));
+ }
+ }
+}
+
+// ──────────────────────────────────────────────────────────
+// Init
+// ──────────────────────────────────────────────────────────
+document.addEventListener("DOMContentLoaded", async () => {
+ initMap();
+
+ // Panel close
+ document.getElementById("panelClose").addEventListener("click", closePanel);
+
+ // Escape key
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") closePanel();
+ });
+
+ // Refresh all button
+ document
+ .getElementById("refreshAllBtn")
+ .addEventListener("click", async () => {
+ const btn = document.getElementById("refreshAllBtn");
+ btn.classList.add("spinning");
+ cityDataCache = {};
+ if (selectedCity) {
+ await loadCityDetail(selectedCity);
+ }
+ btn.classList.remove("spinning");
+ });
+
+ // Load cities
+ const cities = await fetchCities();
+ if (cities.length > 0) {
+ addCityMarkers(cities);
+ buildCityList(cities);
+
+ // Fit map to show all markers
+ const bounds = cities.map((c) => [c.lat, c.lon]);
+ map.fitBounds(bounds, { padding: [60, 60], maxZoom: 4 });
+
+ // 启动后台渐进式预加载所有城市的温度
+ loadAllCitiesProgressively(cities);
+ }
+
+ startAutoRefresh();
+});
diff --git a/web/static/index.html b/web/static/index.html
new file mode 100644
index 00000000..c11022a1
--- /dev/null
+++ b/web/static/index.html
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+ PolyWeather — 天气衍生品智能地图
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/web/static/style.css b/web/static/style.css
new file mode 100644
index 00000000..0b07286f
--- /dev/null
+++ b/web/static/style.css
@@ -0,0 +1,968 @@
+/* ──────────────────────────────────────────────────────────
+ PolyWeather Map — Premium Dark Theme
+ ────────────────────────────────────────────────────────── */
+
+:root {
+ /* Core palette */
+ --bg-primary: #0a0e1a;
+ --bg-secondary: #111827;
+ --bg-card: rgba(17, 24, 39, 0.85);
+ --bg-glass: rgba(15, 23, 42, 0.75);
+ --border-glass: rgba(99, 102, 241, 0.15);
+ --border-subtle: rgba(255, 255, 255, 0.06);
+
+ /* Text */
+ --text-primary: #f1f5f9;
+ --text-secondary: #94a3b8;
+ --text-muted: #64748b;
+
+ /* Accents */
+ --accent-cyan: #22d3ee;
+ --accent-blue: #6366f1;
+ --accent-green: #34d399;
+ --accent-orange: #fb923c;
+ --accent-red: #f87171;
+ --accent-yellow: #fbbf24;
+ --accent-purple: #a78bfa;
+
+ /* Risk colors */
+ --risk-high: #ef4444;
+ --risk-medium: #f59e0b;
+ --risk-low: #22c55e;
+
+ /* Spacing */
+ --panel-width: 420px;
+ --header-height: 56px;
+ --sidebar-width: 200px;
+
+ /* Effects */
+ --glass-blur: 20px;
+ --shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.5);
+ --shadow-glow-cyan: 0 0 20px rgba(34, 211, 238, 0.3);
+ --shadow-glow-blue: 0 0 20px rgba(99, 102, 241, 0.3);
+ --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+/* ── Reset & Base ── */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family:
+ "Inter",
+ -apple-system,
+ BlinkMacSystemFont,
+ sans-serif;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ overflow: hidden;
+ height: 100vh;
+ width: 100vw;
+}
+
+/* ── Map ── */
+#map {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1;
+}
+
+/* Remove Leaflet default styling for cleaner look */
+.leaflet-control-attribution {
+ background: var(--bg-glass) !important;
+ color: var(--text-muted) !important;
+ backdrop-filter: blur(8px);
+ border: 1px solid var(--border-subtle) !important;
+ font-size: 10px !important;
+ border-radius: 6px !important;
+ padding: 2px 8px !important;
+}
+.leaflet-control-attribution a {
+ color: var(--text-secondary) !important;
+}
+.leaflet-control-zoom {
+ border: none !important;
+ box-shadow: var(--shadow-lg) !important;
+}
+.leaflet-control-zoom a {
+ background: var(--bg-glass) !important;
+ color: var(--text-primary) !important;
+ backdrop-filter: blur(12px) !important;
+ border: 1px solid var(--border-glass) !important;
+ width: 36px !important;
+ height: 36px !important;
+ line-height: 36px !important;
+ font-size: 16px !important;
+ border-radius: 8px !important;
+ transition: var(--transition);
+}
+.leaflet-control-zoom a:hover {
+ background: rgba(99, 102, 241, 0.2) !important;
+ border-color: var(--accent-blue) !important;
+}
+
+/* ── Header ── */
+#header {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: var(--header-height);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 24px;
+ background: linear-gradient(
+ 180deg,
+ rgba(10, 14, 26, 0.95) 0%,
+ rgba(10, 14, 26, 0.7) 100%
+ );
+ backdrop-filter: blur(16px);
+ border-bottom: 1px solid var(--border-glass);
+}
+
+.brand {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+}
+
+.brand h1 {
+ font-size: 20px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.subtitle {
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--text-muted);
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+}
+
+.header-right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.live-badge {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 12px;
+ border-radius: 20px;
+ background: rgba(34, 197, 94, 0.1);
+ border: 1px solid rgba(34, 197, 94, 0.3);
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--accent-green);
+ letter-spacing: 1px;
+}
+
+.pulse-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent-green);
+ animation: pulse 2s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4);
+ }
+ 50% {
+ opacity: 0.7;
+ box-shadow: 0 0 0 6px rgba(34, 197, 94, 0);
+ }
+}
+
+.refresh-btn {
+ width: 36px;
+ height: 36px;
+ border-radius: 8px;
+ border: 1px solid var(--border-glass);
+ background: var(--bg-glass);
+ color: var(--text-secondary);
+ font-size: 18px;
+ cursor: pointer;
+ transition: var(--transition);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.refresh-btn:hover {
+ background: rgba(99, 102, 241, 0.15);
+ border-color: var(--accent-blue);
+ color: var(--text-primary);
+}
+.refresh-btn.spinning {
+ animation: spin 1s linear infinite;
+}
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* ── City List Sidebar ── */
+.city-list {
+ position: fixed;
+ top: calc(var(--header-height) + 12px);
+ left: 12px;
+ width: var(--sidebar-width);
+ max-height: calc(100vh - var(--header-height) - 24px);
+ z-index: 900;
+ background: var(--bg-glass);
+ backdrop-filter: blur(var(--glass-blur));
+ border: 1px solid var(--border-glass);
+ border-radius: 16px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ box-shadow: var(--shadow-lg);
+}
+
+.city-list-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border-subtle);
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.city-count {
+ background: var(--accent-blue);
+ color: white;
+ font-size: 11px;
+ font-weight: 700;
+ padding: 2px 8px;
+ border-radius: 10px;
+}
+
+.city-list-items {
+ overflow-y: auto;
+ flex: 1;
+ padding: 4px;
+}
+.city-list-items::-webkit-scrollbar {
+ width: 4px;
+}
+.city-list-items::-webkit-scrollbar-track {
+ background: transparent;
+}
+.city-list-items::-webkit-scrollbar-thumb {
+ background: var(--border-glass);
+ border-radius: 2px;
+}
+
+.city-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ border-radius: 10px;
+ cursor: pointer;
+ transition: var(--transition);
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ border: 1px solid transparent;
+}
+.city-item:hover {
+ background: rgba(99, 102, 241, 0.08);
+ color: var(--text-primary);
+ border-color: var(--border-glass);
+}
+.city-item.active {
+ background: rgba(99, 102, 241, 0.15);
+ color: var(--text-primary);
+ border-color: var(--accent-blue);
+}
+
+.city-item .risk-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.city-item .risk-dot.high {
+ background: var(--risk-high);
+ box-shadow: 0 0 6px var(--risk-high);
+}
+.city-item .risk-dot.medium {
+ background: var(--risk-medium);
+ box-shadow: 0 0 6px var(--risk-medium);
+}
+.city-item .risk-dot.low {
+ background: var(--risk-low);
+ box-shadow: 0 0 6px var(--risk-low);
+}
+
+.city-item .city-temp {
+ margin-left: auto;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--accent-cyan);
+ opacity: 0;
+ transition: var(--transition);
+}
+.city-item .city-temp.loaded {
+ opacity: 1;
+}
+
+/* ── Detail Panel ── */
+.detail-panel {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: var(--panel-width);
+ height: 100vh;
+ z-index: 950;
+ background: linear-gradient(
+ 180deg,
+ rgba(10, 14, 26, 0.92) 0%,
+ rgba(15, 23, 42, 0.95) 100%
+ );
+ backdrop-filter: blur(24px);
+ border-left: 1px solid var(--border-glass);
+ box-shadow: -10px 0 60px rgba(0, 0, 0, 0.5);
+ transform: translateX(100%);
+ transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.detail-panel.visible {
+ transform: translateX(0);
+}
+
+.panel-header {
+ padding: 20px 20px 16px;
+ border-bottom: 1px solid var(--border-subtle);
+ position: relative;
+ flex-shrink: 0;
+}
+
+.panel-close {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ border: 1px solid var(--border-glass);
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 14px;
+ cursor: pointer;
+ transition: var(--transition);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.panel-close:hover {
+ background: rgba(248, 113, 113, 0.15);
+ border-color: var(--accent-red);
+ color: var(--accent-red);
+}
+
+.panel-title-area h2 {
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ margin-bottom: 6px;
+}
+
+.panel-meta {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.risk-badge {
+ font-size: 11px;
+ font-weight: 600;
+ padding: 3px 10px;
+ border-radius: 6px;
+ letter-spacing: 0.5px;
+}
+.risk-badge.high {
+ background: rgba(239, 68, 68, 0.15);
+ color: var(--risk-high);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+}
+.risk-badge.medium {
+ background: rgba(245, 158, 11, 0.15);
+ color: var(--risk-medium);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+}
+.risk-badge.low {
+ background: rgba(34, 197, 94, 0.15);
+ color: var(--risk-low);
+ border: 1px solid rgba(34, 197, 94, 0.3);
+}
+
+.local-time {
+ font-size: 12px;
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.panel-body {
+ overflow-y: auto;
+ flex: 1;
+ padding: 0 20px 24px;
+}
+.panel-body::-webkit-scrollbar {
+ width: 4px;
+}
+.panel-body::-webkit-scrollbar-thumb {
+ background: var(--border-glass);
+ border-radius: 2px;
+}
+
+.panel-body section {
+ padding: 18px 0;
+ border-bottom: 1px solid var(--border-subtle);
+}
+.panel-body section:last-child {
+ border-bottom: none;
+}
+
+.panel-body h3 {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 12px;
+ letter-spacing: 0.3px;
+}
+
+/* ── Hero Section ── */
+.hero-section {
+ text-align: center;
+ padding-top: 12px !important;
+}
+
+.hero-temp {
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 2px;
+ margin-bottom: 16px;
+}
+.hero-value {
+ font-size: 56px;
+ font-weight: 800;
+ letter-spacing: -0.04em;
+ line-height: 1;
+ background: linear-gradient(135deg, #fff 30%, var(--accent-cyan));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+.hero-unit {
+ font-size: 20px;
+ font-weight: 400;
+ color: var(--text-muted);
+ margin-top: 8px;
+}
+
+.hero-details {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ margin-bottom: 12px;
+}
+.hero-item {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-subtle);
+ border-radius: 10px;
+ padding: 10px 8px;
+ text-align: center;
+}
+.hero-item .label {
+ display: block;
+ font-size: 10px;
+ color: var(--text-muted);
+ margin-bottom: 4px;
+}
+.hero-item .value {
+ display: block;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-variant-numeric: tabular-nums;
+}
+.hero-item .value.highlight {
+ color: var(--accent-cyan);
+ text-shadow: 0 0 12px rgba(34, 211, 238, 0.3);
+}
+
+.hero-sub {
+ font-size: 12px;
+ color: var(--text-muted);
+ display: flex;
+ justify-content: center;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+.hero-sub span {
+ white-space: nowrap;
+}
+
+/* ── Chart Section ── */
+.chart-wrapper {
+ height: 180px;
+ position: relative;
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 12px;
+ border: 1px solid var(--border-subtle);
+ padding: 12px;
+}
+
+.chart-legend {
+ display: flex;
+ justify-content: center;
+ gap: 16px;
+ margin-top: 8px;
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+/* ── Probability Bars ── */
+.prob-bars {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.prob-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.prob-label {
+ width: 80px;
+ font-size: 13px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+ flex-shrink: 0;
+}
+
+.prob-bar-track {
+ flex: 1;
+ height: 28px;
+ background: rgba(255, 255, 255, 0.04);
+ border-radius: 8px;
+ overflow: hidden;
+ position: relative;
+}
+
+.prob-bar-fill {
+ height: 100%;
+ border-radius: 8px;
+ transition: width 0.8s cubic-bezier(0.16, 1, 0.3, 1);
+ display: flex;
+ align-items: center;
+ padding-left: 10px;
+ font-size: 12px;
+ font-weight: 600;
+ color: white;
+ min-width: 40px;
+}
+
+.prob-bar-fill.rank-0 {
+ background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan));
+ box-shadow: 0 0 12px rgba(99, 102, 241, 0.3);
+}
+.prob-bar-fill.rank-1 {
+ background: linear-gradient(
+ 90deg,
+ rgba(99, 102, 241, 0.6),
+ rgba(34, 211, 238, 0.5)
+ );
+}
+.prob-bar-fill.rank-2 {
+ background: rgba(99, 102, 241, 0.3);
+}
+.prob-bar-fill.rank-3 {
+ background: rgba(99, 102, 241, 0.15);
+}
+
+/* ── Model Bars ── */
+.model-bars {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.model-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 12px;
+}
+
+.model-name {
+ width: 80px;
+ text-align: right;
+ color: var(--text-muted);
+ font-weight: 500;
+ flex-shrink: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.model-bar-track {
+ flex: 1;
+ height: 20px;
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 6px;
+ position: relative;
+ overflow: hidden;
+}
+
+.model-bar-fill {
+ height: 100%;
+ border-radius: 6px;
+ background: linear-gradient(90deg, var(--accent-purple), var(--accent-blue));
+ transition: width 0.6s ease-out;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding-right: 8px;
+ font-size: 11px;
+ font-weight: 600;
+ color: white;
+}
+
+.model-bar-fill.deb {
+ background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
+ box-shadow: 0 0 8px rgba(34, 211, 238, 0.3);
+}
+
+.model-deb-line {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background: var(--accent-cyan);
+ box-shadow: 0 0 6px var(--accent-cyan);
+ z-index: 2;
+}
+
+/* ── Forecast Table ── */
+.forecast-table {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+ gap: 8px;
+}
+
+.forecast-day {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-subtle);
+ border-radius: 10px;
+ padding: 10px;
+ text-align: center;
+ transition: var(--transition);
+}
+.forecast-day:hover {
+ border-color: var(--border-glass);
+ background: rgba(255, 255, 255, 0.05);
+}
+.forecast-day .f-date {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-bottom: 4px;
+}
+.forecast-day .f-temp {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+.forecast-day.today {
+ border-color: var(--accent-blue);
+ background: rgba(99, 102, 241, 0.08);
+}
+.forecast-day.today .f-date {
+ color: var(--accent-cyan);
+}
+
+.sun-info {
+ margin-top: 10px;
+ font-size: 12px;
+ color: var(--text-muted);
+ display: flex;
+ gap: 16px;
+ justify-content: center;
+}
+
+/* ── AI Section ── */
+.ai-box {
+ background: rgba(99, 102, 241, 0.06);
+ border: 1px solid rgba(99, 102, 241, 0.15);
+ border-radius: 12px;
+ padding: 16px;
+ font-size: 13px;
+ line-height: 1.7;
+ color: var(--text-secondary);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.ai-placeholder {
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+/* ── Risk Section ── */
+.risk-info {
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.8;
+}
+.risk-info .risk-row {
+ display: flex;
+ gap: 8px;
+}
+.risk-info .risk-label {
+ color: var(--text-muted);
+ min-width: 60px;
+}
+
+/* ── Custom Map Markers ── */
+.city-marker {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ cursor: pointer;
+ transition: transform 0.2s ease;
+}
+.city-marker:hover {
+ transform: scale(1.15);
+ z-index: 1000 !important;
+}
+
+.marker-bubble {
+ min-width: 44px;
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-family: "Inter", sans-serif;
+ font-size: 13px;
+ font-weight: 700;
+ text-align: center;
+ color: white;
+ white-space: nowrap;
+ position: relative;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
+ border: 1.5px solid rgba(255, 255, 255, 0.15);
+}
+
+.marker-bubble::after {
+ content: "";
+ position: absolute;
+ bottom: -6px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0;
+ height: 0;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-top: 6px solid;
+ border-top-color: inherit;
+}
+
+.marker-bubble.risk-high {
+ background: linear-gradient(135deg, #dc2626, #ef4444);
+ border-color: rgba(239, 68, 68, 0.5);
+}
+.marker-bubble.risk-high::after {
+ border-top-color: #ef4444;
+}
+
+.marker-bubble.risk-medium {
+ background: linear-gradient(135deg, #d97706, #f59e0b);
+ border-color: rgba(245, 158, 11, 0.5);
+}
+.marker-bubble.risk-medium::after {
+ border-top-color: #f59e0b;
+}
+
+.marker-bubble.risk-low {
+ background: linear-gradient(135deg, #059669, #10b981);
+ border-color: rgba(16, 185, 129, 0.5);
+}
+.marker-bubble.risk-low::after {
+ border-top-color: #10b981;
+}
+
+.marker-name {
+ font-size: 10px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.85);
+ text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
+ margin-top: 2px;
+ white-space: nowrap;
+}
+
+/* Marker glow animation for selected city */
+.city-marker.selected .marker-bubble {
+ animation: markerGlow 2s ease-in-out infinite;
+}
+@keyframes markerGlow {
+ 0%,
+ 100% {
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
+ }
+ 50% {
+ box-shadow:
+ 0 4px 24px rgba(99, 102, 241, 0.5),
+ 0 0 40px rgba(99, 102, 241, 0.2);
+ }
+}
+
+/* ── Loading Overlay ── */
+.loading-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ background: rgba(10, 14, 26, 0.7);
+ backdrop-filter: blur(8px);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+ font-size: 14px;
+ color: var(--text-secondary);
+}
+.loading-overlay.hidden {
+ display: none;
+}
+
+.loading-spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid var(--border-glass);
+ border-top-color: var(--accent-cyan);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+.hidden {
+ display: none !important;
+}
+
+/* ── Trend badge ── */
+.trend-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 10px;
+ border-radius: 6px;
+ font-size: 11px;
+ font-weight: 600;
+}
+.trend-badge.rising {
+ background: rgba(34, 197, 94, 0.12);
+ color: var(--accent-green);
+ border: 1px solid rgba(34, 197, 94, 0.25);
+}
+.trend-badge.falling {
+ background: rgba(248, 113, 113, 0.12);
+ color: var(--accent-red);
+ border: 1px solid rgba(248, 113, 113, 0.25);
+}
+.trend-badge.stagnant {
+ background: rgba(251, 191, 36, 0.12);
+ color: var(--accent-yellow);
+ border: 1px solid rgba(251, 191, 36, 0.25);
+}
+.trend-badge.mixed {
+ background: rgba(167, 139, 250, 0.12);
+ color: var(--accent-purple);
+ border: 1px solid rgba(167, 139, 250, 0.25);
+}
+
+.dead-market {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 10px;
+ border-radius: 6px;
+ font-size: 11px;
+ font-weight: 700;
+ background: rgba(248, 113, 113, 0.15);
+ color: var(--accent-red);
+ border: 1px solid rgba(248, 113, 113, 0.3);
+ animation: deadPulse 2s ease-in-out infinite;
+}
+@keyframes deadPulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.6;
+ }
+}
+
+/* ── Responsive ── */
+@media (max-width: 900px) {
+ .city-list {
+ display: none;
+ }
+ .detail-panel {
+ width: 100%;
+ }
+ :root {
+ --panel-width: 100%;
+ }
+}
+
+@media (max-width: 600px) {
+ .subtitle {
+ display: none;
+ }
+ .brand h1 {
+ font-size: 16px;
+ }
+ .hero-value {
+ font-size: 42px;
+ }
+ .hero-details {
+ grid-template-columns: repeat(3, 1fr);
+ gap: 4px;
+ }
+ .hero-item .value {
+ font-size: 14px;
+ }
+}