diff --git a/web/app.py b/web/app.py
index 3c69e106..3ca42847 100644
--- a/web/app.py
+++ b/web/app.py
@@ -19,8 +19,6 @@ if _root not in sys.path:
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.staticfiles import StaticFiles
-from fastapi.responses import FileResponse
from loguru import logger
from src.utils.config_loader import load_config
@@ -45,10 +43,6 @@ app.add_middleware(
allow_headers=["*"],
)
-_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)
@@ -542,11 +536,6 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
# ──────────────────────────────────────────────────────────
# 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."""
diff --git a/web/static/app.js b/web/static/app.js
deleted file mode 100644
index 38ed7485..00000000
--- a/web/static/app.js
+++ /dev/null
@@ -1,1416 +0,0 @@
-/**
- * PolyWeather 地图 — 前端应用
- * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- * Leaflet 地图 + 详情面板 + Chart.js 温度走势
- */
-
-// ──────────────────────────────────────────────────────────
-// State
-// ──────────────────────────────────────────────────────────
-let map = null;
-let markers = {}; // cityName → Leaflet marker
-let cityDataCache = {}; // cityName → API response
-const CACHE_KEY = "polyWeather_v1";
-
-try {
- const cachedStr = sessionStorage.getItem(CACHE_KEY);
- if (cachedStr) {
- const parsed = JSON.parse(cachedStr);
- if (Date.now() - parsed.timestamp < 5 * 60 * 1000) {
- cityDataCache = parsed.data || {};
- } else {
- sessionStorage.removeItem(CACHE_KEY);
- }
- }
-} catch (e) {
- console.warn("Restore cache failed", e);
-}
-
-function saveCache() {
- try {
- sessionStorage.setItem(
- CACHE_KEY,
- JSON.stringify({ timestamp: Date.now(), data: cityDataCache }),
- );
- } catch (e) {}
-}
-
-let selectedCity = null;
-let tempChart = null;
-const AUTO_REFRESH_MS = 60 * 60 * 1000; // 1 hour
-let selectedForecastDate = null;
-let nearbyLayerGroup = null;
-
-// ──────────────────────────────────────────────────────────
-// Map Setup
-// ──────────────────────────────────────────────────────────
-function initMap() {
- map = L.map("map", {
- center: [30, 10],
- zoom: 3,
- minZoom: 2,
- maxZoom: 12,
- zoomControl: false,
- attributionControl: true,
- });
-
- // Move zoom control to bottom right to avoid overlapping with city list
- L.control.zoom({ position: "bottomright" }).addTo(map);
-
- // 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);
-
- nearbyLayerGroup = L.layerGroup().addTo(map);
-
- // Close panel and clear selection when clicking on empty map space
- map.on("click", () => {
- closePanel();
- });
-
- // Handle zoom-based visibility for local stations and minor cities
- map.on("zoomend", updateMapVisibility);
-}
-
-function updateMapVisibility() {
- if (!map) return;
- const zoom = map.getZoom();
-
- // 1. Handle Nearby Individual Stations (very high zoom only)
- // These are the "Ankara-style" local station markers
- if (zoom < 7) {
- if (map.hasLayer(nearbyLayerGroup)) map.removeLayer(nearbyLayerGroup);
- } else {
- if (!map.hasLayer(nearbyLayerGroup)) map.addLayer(nearbyLayerGroup);
- }
-
- // 2. Keep all primary city markers visible at all zoom levels.
- // This avoids cities like Ankara disappearing when zoomed out.
- Object.values(markers).forEach(({ marker }) => {
- if (!map.hasLayer(marker)) map.addLayer(marker);
- });
-}
-
-// ──────────────────────────────────────────────────────────
-// Markers
-// ──────────────────────────────────────────────────────────
-function createMarkerIcon(city) {
- const riskClass = `risk-${city.risk_level}`;
- const label = city.display_name;
- // Short name for marker
- const unitSym = city.temp_unit === "fahrenheit" ? "°F" : "°C";
- const shortName = label.length > 10 ? label.substring(0, 8) + "…" : label;
- const tempText = city._temp !== undefined ? `${city._temp}${unitSym}` : "—";
-
- 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;
- updateMapVisibility();
-}
-
-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";
- const cityId = city.name.replace(/\s/g, "-");
- div.id = `city-item-${cityId}`;
- div.innerHTML = `
-
-
- ${city.display_name}
- —
-
-
-
-
-
- `;
- div.addEventListener("click", () => {
- loadCityDetail(city.name);
- });
- 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 updateCityListInfo(cityData) {
- const cityName = cityData.name;
- const cityId = cityName.replace(/\s/g, "-");
- const temp =
- cityData.current?.max_so_far != null &&
- cityData.current.max_so_far >= (cityData.current.temp || -999)
- ? cityData.current.max_so_far
- : cityData.current.temp;
-
- // Update Temperature
- const tempEl = document.getElementById(`temp-${cityId}`);
- if (tempEl && temp != null) {
- tempEl.textContent = `${temp}${cityData.temp_symbol}`;
- tempEl.classList.add("loaded");
- }
-
- // Update Local Time
- const timeEl = document.getElementById(`time-${cityId}`);
- if (timeEl && cityData.local_time) {
- timeEl.textContent = `🕐 ${cityData.local_time}`;
- }
-
- // Update Max Temp Time
- const maxEl = document.getElementById(`max-${cityId}`);
- if (maxEl && cityData.current?.max_temp_time) {
- maxEl.textContent = `峰值 @${cityData.current.max_temp_time}`;
- }
-}
-
-// ──────────────────────────────────────────────────────────
-// 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, force = false) {
- const urlName = cityName.replace(/\s/g, "-");
- const res = await fetch(
- `/api/city/${encodeURIComponent(urlName)}?force_refresh=${force}`,
- );
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- return await res.json();
-}
-
-// ──────────────────────────────────────────────────────────
-// Nearby Map Stations Rendering
-// ──────────────────────────────────────────────────────────
-function renderNearbyStations(data) {
- if (!nearbyLayerGroup) return;
- nearbyLayerGroup.clearLayers();
-
- if (!data.mgm_nearby || data.mgm_nearby.length === 0) {
- // Regular city zoom-in
- if (data.lat != null && data.lon != null) {
- map.flyTo([data.lat, data.lon], 10, {
- animate: true,
- duration: 1.5,
- easeLinearity: 0.25,
- });
- }
- return;
- }
-
- const latLngs = [];
-
- // Add main city coordinate so it stays in bounds
- if (data.lat != null && data.lon != null) {
- latLngs.push([data.lat, data.lon]);
- }
-
- data.mgm_nearby.forEach((st) => {
- // Filter out stations too far if needed, but il handles grouping nicely.
- // Skip if it is the exact same marker as main (though coordinates might slightly differ)
- const sym = data.temp_symbol || "°C";
- // Wind info if available
- let windHtml = "";
- if (st.wind_dir != null) {
- const rot = (parseFloat(st.wind_dir) + 180) % 360;
- const speedRaw = parseFloat(st.wind_speed);
- const speed = !isNaN(speedRaw) ? `${speedRaw.toFixed(1)}k` : "";
- windHtml = `
-
- ↑
- ${speed}
-
- `;
- }
-
- const iconHtml = `
-
- ${st.name}
- ${st.temp}${sym}
- ${windHtml}
-
- `;
- const icon = L.divIcon({
- html: iconHtml,
- className: "",
- iconSize: null,
- iconAnchor: [0, 0],
- });
-
- const marker = L.marker([st.lat, st.lon], { icon }).addTo(nearbyLayerGroup);
- latLngs.push([st.lat, st.lon]);
- });
-
- if (latLngs.length > 1) {
- const bounds = L.latLngBounds(latLngs);
- map.flyToBounds(bounds, {
- padding: [40, 40],
- duration: 1.5,
- easeLinearity: 0.25,
- maxZoom: 10, // Don't zoom in extremely close if bounds are tight
- });
- } else if (data.lat != null && data.lon != null) {
- map.flyTo([data.lat, data.lon], 10, {
- animate: true,
- duration: 1.5,
- easeLinearity: 0.25,
- });
- }
-}
-
-// ──────────────────────────────────────────────────────────
-// Load & Render City Detail
-// ──────────────────────────────────────────────────────────
-async function loadCityDetail(cityName, force = false) {
- selectedCity = cityName;
- selectedForecastDate = null; // Reset selection for new city
- setActiveCityItem(cityName);
- setSelectedMarker(cityName);
-
- if (!force && cityDataCache[cityName]) {
- const cachedData = cityDataCache[cityName];
- renderPanel(cachedData);
- renderNearbyStations(cachedData);
- return;
- }
-
- showLoading(true);
-
- try {
- const data = await fetchCityDetail(cityName, force);
- cityDataCache[cityName] = data;
- saveCache();
- renderPanel(data);
-
- // Render nearby stations and zoom camera (cinematic or bounds)
- renderNearbyStations(data);
-
- // Update marker and list
- if (data.current?.temp != null) {
- const displayTemp =
- data.current.max_so_far != null &&
- data.current.max_so_far >= data.current.temp
- ? data.current.max_so_far
- : data.current.temp;
- updateMarkerTemp(cityName, displayTemp);
- updateCityListInfo(data);
- }
- } 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 & Forecast synchronization
- if (!selectedForecastDate) {
- selectedForecastDate = data.local_date;
- }
- renderModels(data);
- renderForecast(data);
- // AI
- renderAI(data);
- // Risk
- renderRisk(data);
-}
-
-const METAR_WX_MAP = {
- RA: { label: "降雨", icon: "🌧️" },
- "-RA": { label: "轻雨", icon: "🌦️" },
- "+RA": { label: "强降雨", icon: "⛈️" },
- SN: { label: "降雪", icon: "❄️" },
- "-SN": { label: "轻雪", icon: "🌨️" },
- "+SN": { label: "大雪", icon: "🏔️" },
- DZ: { label: "毛毛雨", icon: "🌦️" },
- FG: { label: "雾", icon: "🌫️" },
- BR: { label: "薄雾", icon: "🌫️" },
- HZ: { label: "霾", icon: "🌫️" },
- TS: { label: "雷暴", icon: "⛈️" },
- VCTS: { label: "附近雷暴", icon: "⛈️" },
- SQ: { label: "飑", icon: "💨" },
- GS: { label: "冰雹", icon: "🌨️" },
-};
-
-function translateMETAR(code) {
- if (!code) return null;
- // Handle complex codes like "-RA FG" or "TSRA"
- for (const [key, val] of Object.entries(METAR_WX_MAP)) {
- if (code.includes(key)) return val;
- }
- return { label: code, icon: "🌡️" };
-}
-
-function renderHero(data) {
- const cur = data.current || {};
- const sym = data.temp_symbol || "°C";
-
- const displayTemp =
- cur.max_so_far != null && cur.max_so_far >= (cur.temp || -999)
- ? cur.max_so_far
- : cur.temp;
-
- // Use cloud_desc or wx_desc
- let weatherText = cur.cloud_desc || "未知";
- let weatherIcon =
- {
- 多云: "⛅",
- 阴天: "☁️",
- 少云: "🌤️",
- 散云: "⛅",
- 晴: "☀️",
- 晴朗: "☀️",
- }[cur.cloud_desc] || "🌡️";
-
- // If we have a specific weather phenomenon (METAR wx_desc like -RA), prioritize it
- if (cur.wx_desc) {
- const metarTranslation = translateMETAR(cur.wx_desc);
- if (metarTranslation) {
- weatherText = metarTranslation.label;
- weatherIcon = metarTranslation.icon;
- }
- }
-
- document.getElementById("heroWeather").innerHTML = `
- ${weatherIcon} ${weatherText}
- `;
-
- document.getElementById("heroTemp").textContent =
- displayTemp != null ? displayTemp.toFixed(1) : "—";
- document.getElementById("heroUnit").textContent = sym;
-
- // Show if it's the peak recorded temperature
- const isMax = cur.max_so_far != null && cur.max_so_far >= (cur.temp || -999);
- const maxTimeEl = document.getElementById("heroMaxTime");
- if (isMax && cur.max_temp_time) {
- maxTimeEl.textContent = `该城市今日最高温出现于当地时间 ${cur.max_temp_time}`;
- } else {
- maxTimeEl.textContent = "";
- }
-
- document.getElementById("heroCurrent").textContent =
- cur.temp != null ? `${cur.temp}${sym} @${cur.obs_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}`);
- }
- // Use translated wx_desc if available
- if (cur.wx_desc) {
- const trans = translateMETAR(cur.wx_desc);
- parts.push(`${trans.icon} ${trans.label}`);
- } else if (cur.cloud_desc) {
- // Already in hero, but keep it in sub if user wants detail
- 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`);
- }
-
- // MGM info (Ankara specific)
- if (data.mgm?.temp != null) {
- let mgmTimeStr = "";
- if (data.mgm.time) {
- if (data.mgm.time.includes(":")) {
- const match = data.mgm.time.match(/T?(\d{2}:\d{2})/);
- if (match) mgmTimeStr = ` @${match[1]}`;
- }
- }
- parts.push(
- `⭐ MGM 实测: ${data.mgm.temp}${sym}${mgmTimeStr}`,
- );
- }
-
- // 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;
- }
-
- // Current hour index
- const curHour = data.local_time
- ? data.local_time.split(":")[0] + ":00"
- : null;
- const curIdx = curHour ? times.indexOf(curHour) : -1;
-
- // === DEB-adjusted curve ===
- // Shift the OM hourly shape so its peak matches DEB prediction
- const omMax = data.forecast?.today_high;
- const debMax = data.deb?.prediction;
- const offset = debMax != null && omMax != null ? debMax - omMax : 0;
-
- const debTemps = temps.map((t) =>
- t != null ? +(t + offset).toFixed(1) : null,
- );
-
- // Split DEB curve: past = solid, future = dashed
- const debPast = debTemps.map((t, i) =>
- curIdx >= 0 && i <= curIdx ? t : null,
- );
- const debFuture = debTemps.map((t, i) =>
- curIdx < 0 || i >= curIdx ? t : null,
- );
-
- // METAR observation scatter points — use full today's obs if available
- const metarPoints = new Array(times.length).fill(null);
- const metarSrc = data.metar_today_obs?.length
- ? data.metar_today_obs
- : data.trend?.recent || [];
- if (metarSrc.length > 0) {
- metarSrc.forEach((r) => {
- const parts = r.time.split(":");
- let h = parseInt(parts[0], 10);
- const m = parseInt(parts[1] || "0", 10);
- if (m >= 30) h = (h + 1) % 24;
- const hourKey = h.toString().padStart(2, "0") + ":00";
- const idx = times.indexOf(hourKey);
- if (idx >= 0 && metarPoints[idx] === null) {
- metarPoints[idx] = r.temp;
- }
- });
- }
-
- // MGM observation point (Ankara specific)
- const mgmPoints = new Array(times.length).fill(null);
- if (data.mgm?.temp != null && data.mgm?.time) {
- const timeMatch = data.mgm.time.match(/T?(\d{2}):(\d{2})/);
- if (timeMatch) {
- let h = parseInt(timeMatch[1], 10);
- const m = parseInt(timeMatch[2], 10);
- if (m >= 30) h = (h + 1) % 24;
- const hourKey = h.toString().padStart(2, "0") + ":00";
- const idx = times.indexOf(hourKey);
- if (idx >= 0) {
- mgmPoints[idx] = data.mgm.temp;
- }
- }
- }
-
- const ctx = document.getElementById("tempChart").getContext("2d");
- if (tempChart) tempChart.destroy();
-
- // MGM Hourly Forecast (Ankara specific)
- const mgmHourlyPoints = new Array(times.length).fill(null);
- let hasMgmHourly = false;
- if (data.mgm?.hourly?.length > 0) {
- data.mgm.hourly.forEach((hData) => {
- const match = hData.time.match(/T?(\d{2}):(\d{2})/);
- if (match) {
- const hourKey = match[1] + ":00";
- const idx = times.indexOf(hourKey);
- if (idx >= 0) {
- mgmHourlyPoints[idx] = hData.temp;
- hasMgmHourly = true;
- }
- }
- });
- }
-
- const validDebTemps = debTemps.filter((t) => t != null);
- const validMetar = metarPoints.filter((t) => t != null);
- const validMgm = mgmPoints.filter((t) => t != null);
- const validMgmHourly = mgmHourlyPoints.filter((t) => t != null);
- const allVals = [
- ...validDebTemps,
- ...validMetar,
- ...validMgm,
- ...validMgmHourly,
- ];
- if (allVals.length === 0) {
- document.getElementById("chartLegend").textContent = "暂无数据";
- return;
- }
- const minTemp = Math.floor(Math.min(...allVals)) - 1;
- const maxTemp = Math.ceil(Math.max(...allVals)) + 1;
-
- // Build datasets
- const datasets = [];
-
- if (hasMgmHourly) {
- // If MGM is available, replace DEB curve with MGM hourly curve
- datasets.push({
- label: "MGM预报",
- data: mgmHourlyPoints,
- borderColor: "rgba(234, 179, 8, 0.8)", // Yellow
- backgroundColor: "rgba(234, 179, 8, 0.05)",
- borderWidth: 2,
- pointRadius: 3,
- pointHoverRadius: 6,
- fill: false,
- tension: 0.3,
- spanGaps: true, // Connect gaps because MGM is every 3 hours
- });
- } else {
- // Standard DEB curves
- datasets.push({
- label: "DEB预期",
- data: debPast,
- borderColor: "rgba(52, 211, 153, 0.6)",
- backgroundColor: "rgba(52, 211, 153, 0.05)",
- borderWidth: 1.5,
- pointRadius: 0,
- pointHoverRadius: 3,
- fill: true,
- tension: 0.3,
- spanGaps: false,
- });
- datasets.push({
- label: "DEB预报",
- data: debFuture,
- borderColor: "rgba(52, 211, 153, 0.35)",
- borderWidth: 1.5,
- borderDash: [5, 3],
- pointRadius: 0,
- fill: false,
- tension: 0.3,
- spanGaps: false,
- });
- }
-
- // Add METAR
- datasets.push({
- label: "METAR实测",
- data: metarPoints,
- borderColor: "#22d3ee",
- backgroundColor: "#22d3ee",
- borderWidth: 0,
- pointRadius: 5,
- pointHoverRadius: 7,
- pointStyle: "circle",
- fill: false,
- order: 0,
- });
-
- if (validMgm.length > 0) {
- datasets.push({
- label: "MGM实测",
- data: mgmPoints,
- borderColor: "#facc15",
- backgroundColor: "#facc15",
- borderWidth: 0,
- pointRadius: 7,
- pointHoverRadius: 9,
- pointStyle: "star",
- fill: false,
- showLine: false,
- order: -1, // Draw on very top
- });
- }
-
- // Add subtle OM reference line if DEB offset is significant, ONLY if we aren't replacing with MGM
- if (!hasMgmHourly && Math.abs(offset) > 0.3) {
- datasets.push({
- label: "OM原始",
- data: temps,
- borderColor: "rgba(99, 102, 241, 0.2)",
- borderWidth: 1,
- borderDash: [2, 4],
- pointRadius: 0,
- fill: false,
- tension: 0.3,
- });
- }
-
- tempChart = new Chart(ctx, {
- type: "line",
- data: { labels: times, datasets },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- interaction: { intersect: false, mode: "index" },
- plugins: {
- legend: { display: false },
- tooltip: {
- backgroundColor: "rgba(15, 23, 42, 0.9)",
- borderColor: "rgba(52, 211, 153, 0.3)",
- borderWidth: 1,
- titleFont: { family: "Inter", size: 12 },
- bodyFont: { family: "Inter", size: 12 },
- filter: (item) => item.parsed.y != null,
- 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 max line annotation (horizontal)
- if (debMax != null) {
- tempChart.options.plugins.annotation = {
- annotations: {
- debLine: {
- type: "line",
- yMin: debMax,
- yMax: debMax,
- borderColor: "#34d399",
- borderWidth: 1.5,
- borderDash: [6, 3],
- label: {
- display: true,
- content: `DEB ${debMax}${data.temp_symbol}`,
- position: "end",
- backgroundColor: "rgba(52, 211, 153, 0.15)",
- color: "#34d399",
- font: { size: 10, family: "Inter" },
- },
- },
- },
- };
- tempChart.update();
- }
-
- // Chart legend text
- const legend = document.getElementById("chartLegend");
- const legendParts = [];
- if (data.mgm?.temp != null) {
- legendParts.push(`MGM: ${data.mgm.temp}${data.temp_symbol}`);
- }
- if (
- !hasMgmHourly &&
- debMax != null &&
- omMax != null &&
- Math.abs(offset) > 0.3
- ) {
- const sign = offset > 0 ? "+" : "";
- legendParts.push(`DEB偏移 ${sign}${offset.toFixed(1)}° vs OM`);
- }
- if (hasMgmHourly) {
- legendParts.push(`已使用MGM高精预报替换DEB分析曲线`);
- }
- if (data.trend?.recent?.length) {
- const recentStr = [...data.trend.recent]
- .slice(0, 4)
- .reverse()
- .map((r) => `${r.temp}${data.temp_symbol}@${r.time}`)
- .join(" → ");
- legendParts.push(`METAR: ${recentStr}`);
- }
- legend.textContent = legendParts.join(" ┃ ") || "";
-}
-
-function renderProbabilities(data) {
- const container = document.getElementById("probBars");
- const targetDate = selectedForecastDate || data.local_date;
-
- let probs = [];
- let mu = null;
-
- if (targetDate === data.local_date) {
- probs = data.probabilities?.distribution || [];
- mu = data.probabilities?.mu;
- } else if (data.multi_model_daily && data.multi_model_daily[targetDate]) {
- probs = data.multi_model_daily[targetDate].probabilities || [];
- mu = data.multi_model_daily[targetDate].deb?.prediction;
- }
-
- 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);
- html += `
-
-
${p.value}${data.temp_symbol}
-
-
- `;
- });
- container.innerHTML = html;
-
- // Animate bars
- requestAnimationFrame(() => {
- container.querySelectorAll(".prob-bar-fill").forEach((bar, i) => {
- const pct = Math.round(probs[i].probability * 100);
- bar.style.width = Math.max(pct, 8) + "%";
- });
- });
-}
-
-function escapeHtml(value) {
- return String(value ?? "").replace(/[&<>"']/g, (ch) => {
- return (
- {
- "&": "&",
- "<": "<",
- ">": ">",
- '"': """,
- "'": "'",
- }[ch] || ch
- );
- });
-}
-
-function formatCents(price) {
- const n = Number(price);
- if (!Number.isFinite(n)) return "--";
- const cents = Math.round(n * 1000) / 10;
- return Number.isInteger(cents) ? `${cents.toFixed(0)}c` : `${cents.toFixed(1)}c`;
-}
-
-function renderModels(data) {
- const container = document.getElementById("modelBars");
- const targetDate = selectedForecastDate || data.local_date;
-
- let models = {};
- let deb = null;
-
- if (data.multi_model_daily && data.multi_model_daily[targetDate]) {
- models = data.multi_model_daily[targetDate].models || {};
- deb = data.multi_model_daily[targetDate].deb?.prediction;
- } else {
- models = data.multi_model || {};
- 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 isSelected = d.date === selectedForecastDate;
- const dateLabel = isToday ? "今天" : d.date.substring(5).replace("-", "/");
-
- 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 switchForecastDate(cityName, dateStr) {
- if (selectedCity !== cityName) return;
- selectedForecastDate = dateStr;
-
- const data = cityDataCache[cityName];
- if (data) {
- renderModels(data);
- renderProbabilities(data);
- renderForecast(data);
- }
-}
-
-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) {
- const displayTemp =
- data.current.max_so_far != null &&
- data.current.max_so_far >= data.current.temp
- ? data.current.max_so_far
- : data.current.temp;
- updateMarkerTemp(selectedCity, displayTemp);
- updateCityListInfo(data);
- }
- 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;
- saveCache();
-
- // 如果用户目前没有点击它,仅更新标记和列表
- if (data.current?.temp != null) {
- const displayTemp =
- data.current.max_so_far != null &&
- data.current.max_so_far >= data.current.temp
- ? data.current.max_so_far
- : data.current.temp;
- updateMarkerTemp(city.name, displayTemp);
- updateCityListInfo(data);
- }
-
- // 如果恰好在这个时候用户选中了这个城市,顺便刷新面板
- if (selectedCity === city.name) {
- renderPanel(data);
- }
- }
- } catch (e) {
- console.warn(`Background load failed for ${city.name}`, e);
- }
- // 间隔 2000ms,避免瞬间并发轰炸后端 API,且让出浏览器主线程
- await new Promise((r) => setTimeout(r, 2000));
- }
- }
-}
-
-// ──────────────────────────────────────────────────────────
-// History Chart Logic
-// ──────────────────────────────────────────────────────────
-let historyChartInst = null;
-
-async function openHistoryModal() {
- if (!selectedCity) return;
-
- const modal = document.getElementById("historyModal");
- const title = document.getElementById("historyModalTitle");
- const statsDiv = document.getElementById("historyStats");
-
- modal.classList.remove("hidden");
- title.textContent = `历史准确率对账 - ${selectedCity.toUpperCase()}`;
- statsDiv.innerHTML =
- '正在获取底层数据库...';
-
- try {
- const res = await fetch(`/api/history/${encodeURIComponent(selectedCity)}`);
- const json = await res.json();
- const data = json.history || [];
-
- if (data.length === 0) {
- statsDiv.innerHTML =
- '暂无该城市历史数据';
- if (historyChartInst) historyChartInst.destroy();
- return;
- }
-
- // Compute stats
- let hits = 0;
- let debErrors = [];
- let muErrors = [];
-
- const dates = [];
- const actuals = [];
- const debs = [];
- const mus = [];
- const mgms = [];
-
- data.forEach((row) => {
- dates.push(row.date);
- actuals.push(row.actual);
- debs.push(row.deb);
- mus.push(row.mu);
- mgms.push(row.mgm);
-
- if (row.actual != null && row.deb != null) {
- debErrors.push(Math.abs(row.actual - row.deb));
- if (Math.round(row.actual) === Math.round(row.deb)) {
- hits++;
- }
- }
- if (row.actual != null && row.mu != null) {
- muErrors.push(Math.abs(row.actual - row.mu));
- }
- });
-
- const hitRate = debErrors.length
- ? ((hits / debErrors.length) * 100).toFixed(0)
- : 0;
- const debMae = debErrors.length
- ? (debErrors.reduce((a, b) => a + b, 0) / debErrors.length).toFixed(1)
- : "-";
- const muMae = muErrors.length
- ? (muErrors.reduce((a, b) => a + b, 0) / muErrors.length).toFixed(1)
- : "-";
-
- statsDiv.innerHTML = `
- DEB 结算胜率 (WU)${hitRate}%
- DEB MAE${debMae}°
- μ (概率) MAE${muMae}°
- 有效样本数${data.length}天
- `;
-
- if (historyChartInst) historyChartInst.destroy();
- const ctx = document.getElementById("historyChart").getContext("2d");
-
- historyChartInst = new Chart(ctx, {
- type: "line",
- data: {
- labels: dates,
- datasets: [
- {
- label: "实测最高温",
- data: actuals,
- borderColor: "#f87171", // red
- backgroundColor: "rgba(248, 113, 113, 0.1)",
- borderWidth: 2,
- tension: 0.2,
- pointRadius: 4,
- pointBackgroundColor: "#f87171",
- pointBorderColor: "#fff",
- zIndex: 10,
- },
- {
- label: "DEB 融合",
- data: debs,
- borderColor: "#34d399", // emerald
- backgroundColor: "transparent",
- borderWidth: 2,
- borderDash: [5, 4],
- tension: 0.2,
- pointRadius: 3,
- },
- {
- label: "μ (概率锚定)",
- data: mus,
- borderColor: "#a78bfa", // purple
- backgroundColor: "transparent",
- borderWidth: 2,
- borderDash: [2, 2],
- tension: 0.2,
- pointRadius: 3,
- },
- {
- label: "MGM 官方预报",
- data: mgms,
- borderColor: "#fb923c", // orange
- backgroundColor: "transparent",
- borderWidth: 2,
- tension: 0.2,
- pointRadius: 3,
- hidden: false, // Show by default for Ankara
- },
- ],
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- interaction: { mode: "index", intersect: false },
- plugins: {
- legend: {
- labels: { color: "#94a3b8", font: { family: "Inter", size: 12 } },
- },
- tooltip: {
- backgroundColor: "rgba(15, 23, 42, 0.9)",
- borderColor: "rgba(255, 255, 255, 0.1)",
- borderWidth: 1,
- titleFont: { family: "Inter" },
- bodyFont: { family: "Inter" },
- callbacks: {
- label: (ctx) =>
- `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
- },
- },
- },
- scales: {
- x: {
- grid: { color: "rgba(255,255,255,0.04)" },
- ticks: { color: "#64748b", font: { family: "Inter", size: 10 } },
- },
- y: {
- grid: { color: "rgba(255,255,255,0.04)" },
- ticks: { color: "#64748b", font: { family: "Inter", size: 10 } },
- },
- },
- },
- });
- } catch (e) {
- console.error("Failed to load history", e);
- statsDiv.innerHTML =
- '获取历史信息失败';
- }
-}
-
-function closeHistoryModal() {
- document.getElementById("historyModal").classList.add("hidden");
-}
-
-document.addEventListener("DOMContentLoaded", async () => {
- initMap();
-
- // Panel close
- document.getElementById("panelClose").addEventListener("click", closePanel);
-
- // Escape key
- document.addEventListener("keydown", (e) => {
- if (e.key === "Escape") closePanel();
- });
-
- // Modal Event Listeners
- const histModal = document.getElementById("historyModal");
- const guideModal = document.getElementById("guideModal");
-
- document
- .getElementById("btnShowHistory")
- .addEventListener("click", openHistoryModal);
- document
- .getElementById("historyModalClose")
- .addEventListener("click", closeHistoryModal);
- histModal.addEventListener("click", (e) => {
- if (e.target.id === "historyModal") closeHistoryModal();
- });
-
- document.getElementById("btnShowGuide").addEventListener("click", () => {
- guideModal.classList.remove("hidden");
- });
- document.getElementById("guideModalClose").addEventListener("click", () => {
- guideModal.classList.add("hidden");
- });
- guideModal.addEventListener("click", (e) => {
- if (e.target.id === "guideModal") guideModal.classList.add("hidden");
- });
-
- // Refresh all button
- document
- .getElementById("refreshAllBtn")
- .addEventListener("click", async () => {
- const btn = document.getElementById("refreshAllBtn");
- btn.classList.add("spinning");
- cityDataCache = {};
- saveCache();
- if (selectedCity) {
- await loadCityDetail(selectedCity, true);
- }
- btn.classList.remove("spinning");
- });
-
- // Load cities
- const cities = await fetchCities();
- if (cities.length > 0) {
- cities.forEach((c) => {
- const cached = cityDataCache[c.name];
- if (cached && cached.current?.temp != null) {
- c._temp =
- cached.current.max_so_far != null &&
- cached.current.max_so_far >= cached.current.temp
- ? cached.current.max_so_far
- : cached.current.temp;
- }
- });
-
- addCityMarkers(cities);
- buildCityList(cities);
-
- cities.forEach((c) => {
- if (cityDataCache[c.name]) {
- updateCityListInfo(cityDataCache[c.name]);
- }
- });
-
- // 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
deleted file mode 100644
index e46673a7..00000000
--- a/web/static/index.html
+++ /dev/null
@@ -1,217 +0,0 @@
-
-
-
-
-
-
- PolyWeather — 天气衍生品智能地图
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
🧬 DEB 动态融合预测
-
Dynamic Ensemble Blending 是系统的核心算法。它不只是简单的平均值,而是根据各个模型(ECMWF, GFS, MGM等)在**过去 30
- 天**内的实际表现,动态分配权重。表现越稳的模型,在最终决策中占比越高。
-
-
-
🎲 结算概率引擎 (μ)
-
基于正态分布模型。我们提取所有预报模型的离散度作为**不确定性系数 ($\sigma$)**,结合 DEB 预测值作为**期望值 ($\mu$)**。这能告诉你某个温度区间(如 10°C
- 盘口)发生的真实概率,辅助对冲决策。
-
-
-
📍 结算点 (Airport) 逻辑
-
Polymarket 结算是以**机场 METAR
- 气象站**为准。在安卡拉等复杂城市,我们同时监控**市中心总站**(潜力热岛)和**机场站**(物理结算点)。当两者温差巨大时,往往预示着随后的补涨跳升机会。
-
-
-
⚠️ 风险偏置档案
-
系统记录了每个城市机场站与市区的距离、海拔差及季度性偏差。例如,首尔仁川机场因靠海,最高温通常比市区偏低;慕尼黑机场因海拔高,夜间降温极快。这些偏置已自动整合进 AI 分析逻辑中。
-
-
-
-
📈 未来日期概率分布
-
当你切换到未来 3-5 天时,系统会基于各模型对该日期的预报分歧度重新计算概率。分歧越大,分布越扁平(风险高);共识越强,分布越尖锐(机会明朗)。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/static/style.css b/web/static/style.css
deleted file mode 100644
index 3cc9db25..00000000
--- a/web/static/style.css
+++ /dev/null
@@ -1,1289 +0,0 @@
-/* ──────────────────────────────────────────────────────────
- 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: 560px;
- --header-height: 56px;
- --sidebar-width: 280px;
-
- /* 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: 16px 18px;
- border-bottom: 1px solid var(--border-subtle);
- font-size: 15px;
- font-weight: 700;
- color: var(--text-primary);
-}
-
-.city-count {
- background: var(--accent-blue);
- color: white;
- font-size: 12px;
- font-weight: 700;
- padding: 3px 10px;
- border-radius: 12px;
-}
-
-.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;
- flex-direction: column;
- gap: 4px;
- padding: 10px 12px;
- border-radius: 10px;
- cursor: pointer;
- transition: var(--transition);
- border: 1px solid transparent;
-}
-.city-item:hover {
- background: rgba(99, 102, 241, 0.08);
- border-color: var(--border-glass);
-}
-.city-item.active {
- background: rgba(99, 102, 241, 0.15);
- border-color: var(--accent-blue);
-}
-
-.city-item-main {
- display: flex;
- align-items: center;
- gap: 10px;
- width: 100%;
-}
-
-.city-item .city-name-text {
- font-size: 15px;
- font-weight: 600;
- color: var(--text-primary);
-}
-
-.city-item .city-temp {
- margin-left: auto;
- font-size: 16px;
- font-weight: 800;
- color: var(--accent-cyan);
- opacity: 0;
- transition: var(--transition);
-}
-.city-item .city-temp.loaded {
- opacity: 1;
-}
-
-.city-item-info {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding-left: 20px; /* Align with name text, after the dot */
- font-size: 11px;
- color: var(--text-muted);
-}
-
-.city-item .city-max-info {
- color: var(--accent-blue);
- font-weight: 500;
-}
-
-.city-item .risk-dot {
- width: 10px;
- height: 10px;
- 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-weather {
- font-size: 13px;
- font-weight: 600;
- color: var(--accent-cyan);
- margin-bottom: -4px;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
-}
-
-.hero-temp {
- display: flex;
- align-items: flex-start;
- justify-content: center;
- gap: 2px;
- margin-bottom: 2px;
-}
-
-.hero-max-time {
- font-size: 10px;
- font-weight: 500;
- color: var(--text-muted);
- margin-bottom: 16px;
- min-height: 12px;
-}
-.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);
-}
-
-.forecast-day.selected {
- border-color: var(--accent-cyan);
- background: rgba(34, 211, 238, 0.1);
- box-shadow: 0 0 12px rgba(34, 211, 238, 0.2);
-}
-.forecast-day.selected .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;
- word-break: break-word; /* 确保数字和英文长句也能折行 */
- white-space: normal;
-}
-.risk-info .risk-row {
- display: flex;
- gap: 8px;
- align-items: flex-start;
-}
-.risk-info .risk-label {
- color: var(--text-muted);
- min-width: 60px;
- flex-shrink: 0;
-}
-
-/* ── Nearby Markers ── */
-.nearby-marker {
- display: flex;
- align-items: center;
- gap: 6px;
- background: rgba(15, 23, 42, 0.9);
- color: var(--text-secondary);
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 8px;
- padding: 4px 10px;
- font-size: 11px;
- font-weight: 500;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
- white-space: nowrap;
- backdrop-filter: blur(8px);
- pointer-events: none;
-}
-
-.nearby-name {
- color: var(--text-muted);
-}
-
-.nearby-temp {
- font-weight: 800;
- color: #fff;
-}
-
-.nearby-unit {
- font-weight: 400;
- font-size: 9px;
- color: var(--text-muted);
-}
-
-.wind-info {
- display: flex;
- align-items: center;
- gap: 4px;
- margin-left: 4px;
- padding-left: 6px;
- border-left: 1px solid rgba(255, 255, 255, 0.1);
-}
-
-.wind-arrow {
- display: inline-block;
- font-size: 12px;
- color: var(--accent-cyan);
- transition: transform 0.3s;
- text-shadow: 0 0 8px rgba(34, 211, 238, 0.5);
-}
-
-.wind-speed {
- font-size: 9px;
- color: var(--text-muted);
- font-variant-numeric: tabular-nums;
-}
-
-@keyframes markerFadeIn {
- from {
- opacity: 0;
- transform: translateY(5px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-/* ── 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: 1100px) {
- :root {
- --panel-width: 460px;
- }
- .hero-value {
- font-size: 52px;
- }
-}
-
-@media (max-width: 768px) {
- .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;
- }
-}
-
-/* ── History Modal ── */
-.history-btn {
- background: rgba(34, 211, 238, 0.1);
- color: var(--accent-cyan);
- border: 1px solid rgba(34, 211, 238, 0.3);
- padding: 4px 10px;
- border-radius: 6px;
- font-size: 11px;
- font-weight: 600;
- cursor: pointer;
- transition: all 0.2s;
- margin-left: 8px;
-}
-.history-btn:hover {
- background: rgba(34, 211, 238, 0.2);
- border-color: var(--accent-cyan);
-}
-
-.modal-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.6);
- backdrop-filter: blur(4px);
- z-index: 10000;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 16px;
-}
-
-.modal-content {
- background: #111827;
- border: 1px solid var(--border-subtle);
- border-radius: 16px;
- width: 100%;
- max-width: 700px;
- box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
- display: flex;
- flex-direction: column;
-}
-
-.modal-header {
- padding: 16px 20px;
- border-bottom: 1px solid var(--border-subtle);
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-.modal-header h2 {
- font-size: 16px;
- font-weight: 600;
- color: var(--text-primary);
- margin: 0;
-}
-.modal-close {
- background: none;
- border: none;
- font-size: 20px;
- color: var(--text-muted);
- cursor: pointer;
- transition: color 0.2s;
-}
-.modal-close:hover {
- color: var(--accent-red);
-}
-
-.modal-body {
- padding: 20px;
-}
-
-.history-stats {
- display: flex;
- gap: 12px;
- margin-bottom: 20px;
- flex-wrap: wrap;
-}
-.h-stat-card {
- flex: 1;
- min-width: 110px;
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid var(--border-subtle);
- border-radius: 8px;
- padding: 12px;
- text-align: center;
-}
-.h-stat-card .label {
- display: block;
- font-size: 11px;
- color: var(--text-muted);
- margin-bottom: 4px;
-}
-.h-stat-card .val {
- display: block;
- font-size: 18px;
- font-weight: 700;
- color: var(--text-primary);
-}
-
-.history-chart-wrapper {
- position: relative;
- height: 300px;
- width: 100%;
-}
-
-/* ── Info Button ── */
-.info-btn {
- background: rgba(99, 102, 241, 0.1);
- border: 1px solid rgba(99, 102, 241, 0.3);
- color: var(--accent-blue);
- padding: 6px 14px;
- border-radius: 8px;
- font-size: 13px;
- font-weight: 600;
- cursor: pointer;
- transition: var(--transition);
- display: flex;
- align-items: center;
- gap: 6px;
-}
-.info-btn:hover {
- background: rgba(99, 102, 241, 0.2);
- border-color: var(--accent-blue);
- transform: translateY(-1px);
-}
-
-/* ── Guide Modal Customizations ── */
-.modal-content.large {
- max-width: 900px;
-}
-
-.guide-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
- gap: 16px;
- padding: 4px;
-}
-
-.guide-card {
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid var(--border-subtle);
- border-radius: 12px;
- padding: 16px;
- transition: var(--transition);
-}
-.guide-card:hover {
- background: rgba(255, 255, 255, 0.05);
- border-color: var(--border-glass);
-}
-.guide-card h3 {
- font-size: 15px;
- font-weight: 700;
- color: var(--accent-cyan);
- margin-bottom: 10px;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-.guide-card p {
- font-size: 13px;
- line-height: 1.6;
- color: var(--text-secondary);
- margin-bottom: 0;
-}
-.guide-card b {
- color: var(--text-primary);
-}
-
-.guide-footer {
- margin-top: 24px;
- padding-top: 16px;
- border-top: 1px solid var(--border-subtle);
- text-align: center;
- font-size: 11px;
- color: var(--text-muted);
-}