feat: Introduce a web interface with an interactive map and implement a Dynamic Ensemble Blending (DEB) algorithm for weather prediction.

This commit is contained in:
2569718930@qq.com
2026-03-03 21:27:57 +08:00
parent 7615ac9656
commit 7926cedc97
7 changed files with 2441 additions and 5 deletions
+13
View File
@@ -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}"
+2
View File
@@ -5,3 +5,5 @@ python-dotenv
pytz
numpy
web3
fastapi
uvicorn
+32 -5
View File
@@ -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}")
+568
View File
@@ -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)
+706
View File
@@ -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:
'&copy; <a href="https://www.openstreetmap.org/">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
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 = `
<div class="city-marker" data-city="${city.name}">
<div class="marker-bubble ${riskClass}">${tempText}</div>
<div class="marker-name">${shortName}</div>
</div>
`;
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 = `
<span class="risk-dot ${city.risk_level}"></span>
<span class="city-name-text">${city.display_name}</span>
<span class="city-temp" id="temp-${city.name.replace(/\s/g, "-")}">—</span>
`;
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(`<span>✈️ METAR ${cur.obs_time}${ageStr}</span>`);
}
if (cur.cloud_desc) parts.push(`<span>☁️ ${cur.cloud_desc}</span>`);
if (cur.wind_speed_kt != null) {
parts.push(`<span>💨 ${cur.wind_speed_kt}kt</span>`);
}
if (cur.visibility_mi != null) {
parts.push(`<span>👁️ ${cur.visibility_mi}mi</span>`);
}
// Trend badge
const trend = data.trend || {};
if (trend.is_dead_market) {
parts.push('<span class="dead-market">☠️ 死盘</span>');
} else if (trend.direction && trend.direction !== "unknown") {
const labels = {
rising: "📈 升温中",
falling: "📉 降温中",
stagnant: "⏸️ 已停滞",
mixed: "📊 波动中",
};
parts.push(
`<span class="trend-badge ${trend.direction}">${labels[trend.direction] || trend.direction}</span>`,
);
}
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 =
'<div style="color:var(--text-muted);font-size:13px;">暂无概率数据</div>';
return;
}
let html = "";
if (mu != null) {
html += `<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;">期望值 μ = ${mu}${data.temp_symbol}</div>`;
}
probs.forEach((p, i) => {
const pct = Math.round(p.probability * 100);
const width = Math.max(pct, 8);
html += `
<div class="prob-row">
<div class="prob-label">${p.value}${data.temp_symbol}</div>
<div class="prob-bar-track">
<div class="prob-bar-fill rank-${i}" style="width:0%">${pct}%</div>
</div>
</div>
`;
});
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 =
'<div style="color:var(--text-muted);font-size:13px;">暂无多模型数据</div>';
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 += `
<div class="model-row">
<div class="model-name" title="${name}">${shortName}</div>
<div class="model-bar-track">
<div class="model-bar-fill" style="width:${pct}%">${val}${data.temp_symbol}</div>
${deb != null ? `<div class="model-deb-line" style="left:${((deb - minVal) / range) * 100}%"></div>` : ""}
</div>
</div>
`;
});
// DEB row
if (deb != null) {
const pct = ((deb - minVal) / range) * 100;
html += `
<div class="model-row" style="margin-top:6px;border-top:1px solid rgba(255,255,255,0.06);padding-top:6px;">
<div class="model-name" style="color:var(--accent-cyan);font-weight:700;">DEB</div>
<div class="model-bar-track">
<div class="model-bar-fill deb" style="width:${pct}%">${deb}${data.temp_symbol}</div>
</div>
</div>
`;
}
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 =
'<div style="color:var(--text-muted);font-size:13px;">暂无预报</div>';
return;
}
let html = "";
daily.forEach((d, i) => {
const isToday = i === 0;
const dateLabel = isToday ? "今天" : d.date.substring(5);
html += `
<div class="forecast-day ${isToday ? "today" : ""}">
<div class="f-date">${dateLabel}</div>
<div class="f-temp">${d.max_temp}${sym}</div>
</div>
`;
});
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) => `<span>${p}</span>`).join("");
}
function renderAI(data) {
const container = document.getElementById("aiAnalysis");
const text = data.ai_analysis || "";
if (!text) {
container.innerHTML = '<span class="ai-placeholder">AI 分析暂不可用</span>';
return;
}
// The AI output may contain HTML tags like <b>
container.innerHTML = text;
}
function renderRisk(data) {
const container = document.getElementById("riskInfo");
const risk = data.risk || {};
if (!risk.airport) {
container.innerHTML =
'<span style="color:var(--text-muted)">无风险档案</span>';
return;
}
container.innerHTML = `
<div class="risk-row"><span class="risk-label">📍 机场</span><span>${risk.airport} (${risk.icao})</span></div>
<div class="risk-row"><span class="risk-label">📏 距离</span><span>${risk.distance_km}km</span></div>
${risk.warning ? `<div class="risk-row"><span class="risk-label">⚠️ 注意</span><span>${risk.warning}</span></div>` : ""}
`;
}
// ──────────────────────────────────────────────────────────
// 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();
});
+152
View File
@@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PolyWeather — 天气衍生品智能地图</title>
<meta name="description" content="Polymarket 天气衍生品交易智能地图。实时 METAR、DEB 融合预报与 AI 分析。">
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Google Fonts: Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- ── Map ── -->
<div id="map"></div>
<!-- ── Header Overlay ── -->
<header id="header">
<div class="brand">
<h1>🌡️ PolyWeather</h1>
<span class="subtitle">天气衍生品智能分析</span>
</div>
<div class="header-right">
<div class="live-badge" id="liveBadge">
<span class="pulse-dot"></span>
<span>实时</span>
</div>
<button class="refresh-btn" id="refreshAllBtn" title="刷新所有数据"></button>
</div>
</header>
<!-- ── City List Sidebar (left) ── -->
<nav id="cityList" class="city-list">
<div class="city-list-header">
<span>🏙️ 监控城市</span>
<span class="city-count" id="cityCount">0</span>
</div>
<div id="cityListItems" class="city-list-items">
<!-- Dynamically populated -->
</div>
</nav>
<!-- ── Detail Panel (right) ── -->
<aside id="panel" class="detail-panel hidden">
<div class="panel-header">
<button class="panel-close" id="panelClose"></button>
<div class="panel-title-area">
<h2 id="panelCityName"></h2>
<div class="panel-meta">
<span id="panelRiskBadge" class="risk-badge"></span>
<span id="panelLocalTime" class="local-time"></span>
</div>
</div>
</div>
<div id="panelContent" class="panel-body">
<!-- ── Temperature Hero ── -->
<section class="hero-section">
<div class="hero-temp">
<span class="hero-value" id="heroTemp"></span>
<span class="hero-unit" id="heroUnit">°C</span>
</div>
<div class="hero-details">
<div class="hero-item">
<span class="label">📈 今日最高</span>
<span class="value" id="heroMax"></span>
</div>
<div class="hero-item">
<span class="label">🎯 WU 结算</span>
<span class="value highlight" id="heroWU"></span>
</div>
<div class="hero-item">
<span class="label">🧬 DEB 预测</span>
<span class="value" id="heroDEB"></span>
</div>
</div>
<div class="hero-sub" id="heroSub">
<!-- obs time, cloud, wind -->
</div>
</section>
<!-- ── Trend Sparkline ── -->
<section class="chart-section">
<h3>📊 今日温度走势</h3>
<div class="chart-wrapper">
<canvas id="tempChart"></canvas>
</div>
<div class="chart-legend" id="chartLegend"></div>
</section>
<!-- ── Probability Bars ── -->
<section class="prob-section">
<h3>🎲 结算概率分布</h3>
<div id="probBars" class="prob-bars">
<!-- Dynamically populated -->
</div>
</section>
<!-- ── Multi-Model Comparison ── -->
<section class="models-section">
<h3>🔬 多模型预报</h3>
<div id="modelBars" class="model-bars">
<!-- Dynamically populated -->
</div>
</section>
<!-- ── Forecast Table ── -->
<section class="forecast-section">
<h3>📅 多日预报</h3>
<div id="forecastTable" class="forecast-table"></div>
<div class="sun-info" id="sunInfo"></div>
</section>
<!-- ── AI Analysis ── -->
<section class="ai-section">
<h3>🤖 AI 深度分析</h3>
<div id="aiAnalysis" class="ai-box">
<span class="ai-placeholder">点击城市后加载...</span>
</div>
</section>
<!-- ── Risk Profile ── -->
<section class="risk-section">
<h3>⚠️ 数据偏差风险</h3>
<div id="riskInfo" class="risk-info"></div>
</section>
</div>
</aside>
<!-- ── Loading Overlay ── -->
<div id="loading" class="loading-overlay hidden">
<div class="loading-spinner"></div>
<span>正在获取气象数据,请稍候...</span>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
+968
View File
@@ -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;
}
}