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}
+
+
${pct}%
+
+
+ `; + }); + 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; + } +}