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}
-
-
${pct}%
-
-
- `; - }); - container.innerHTML = html; - - // Animate bars - requestAnimationFrame(() => { - container.querySelectorAll(".prob-bar-fill").forEach((bar, i) => { - const pct = Math.round(probs[i].probability * 100); - bar.style.width = Math.max(pct, 8) + "%"; - }); - }); -} - -function 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 — 天气衍生品智能地图 - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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); -}