From b308709edb303b7bd70cab01546c63a52fa411d0 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Fri, 6 Mar 2026 09:14:58 +0800
Subject: [PATCH] feat: Introduce a new Next.js modern frontend with a history
API route and include legacy static assets.
---
frontend/app/api/history/[name]/route.ts | 39 +
frontend/app/modern/page.tsx | 5 +
frontend/app/page.tsx | 12 +-
frontend/public/legacy/index.html | 217 ++++
frontend/public/static/app.js | 1399 ++++++++++++++++++++++
frontend/public/static/style.css | 1289 ++++++++++++++++++++
6 files changed, 2958 insertions(+), 3 deletions(-)
create mode 100644 frontend/app/api/history/[name]/route.ts
create mode 100644 frontend/app/modern/page.tsx
create mode 100644 frontend/public/legacy/index.html
create mode 100644 frontend/public/static/app.js
create mode 100644 frontend/public/static/style.css
diff --git a/frontend/app/api/history/[name]/route.ts b/frontend/app/api/history/[name]/route.ts
new file mode 100644
index 00000000..76969c1d
--- /dev/null
+++ b/frontend/app/api/history/[name]/route.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+
+const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
+
+export async function GET(
+ _req: Request,
+ context: { params: Promise<{ name: string }> },
+) {
+ if (!API_BASE) {
+ return NextResponse.json(
+ { error: "POLYWEATHER_API_BASE_URL is not configured" },
+ { status: 500 },
+ );
+ }
+
+ const { name } = await context.params;
+ const url = `${API_BASE}/api/history/${encodeURIComponent(name)}`;
+
+ try {
+ const res = await fetch(url, {
+ headers: { Accept: "application/json" },
+ cache: "no-store",
+ });
+ if (!res.ok) {
+ const raw = await res.text();
+ return NextResponse.json(
+ { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
+ { status: 502 },
+ );
+ }
+ const data = await res.json();
+ return NextResponse.json(data);
+ } catch (error) {
+ return NextResponse.json(
+ { error: "Failed to fetch history", detail: String(error) },
+ { status: 500 },
+ );
+ }
+}
diff --git a/frontend/app/modern/page.tsx b/frontend/app/modern/page.tsx
new file mode 100644
index 00000000..d4abf2bc
--- /dev/null
+++ b/frontend/app/modern/page.tsx
@@ -0,0 +1,5 @@
+import { PolyWeatherDashboard } from "@/components/polyweather-dashboard";
+
+export default function ModernPage() {
+ return ;
+}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index e5af7622..19446599 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -1,5 +1,11 @@
-import { PolyWeatherDashboard } from "@/components/polyweather-dashboard";
-
export default function HomePage() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/frontend/public/legacy/index.html b/frontend/public/legacy/index.html
new file mode 100644
index 00000000..08b17c50
--- /dev/null
+++ b/frontend/public/legacy/index.html
@@ -0,0 +1,217 @@
+
+
+
+
+
+
+ PolyWeather — 天气衍生品智能地图
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🧬 DEB 动态融合预测
+
Dynamic Ensemble Blending 是系统的核心算法。它不只是简单的平均值,而是根据各个模型(ECMWF, GFS, MGM等)在**过去 30
+ 天**内的实际表现,动态分配权重。表现越稳的模型,在最终决策中占比越高。
+
+
+
🎲 结算概率引擎 (μ)
+
基于正态分布模型。我们提取所有预报模型的离散度作为**不确定性系数 ($\sigma$)**,结合 DEB 预测值作为**期望值 ($\mu$)**。这能告诉你某个温度区间(如 10°C
+ 盘口)发生的真实概率,辅助对冲决策。
+
+
+
📍 结算点 (Airport) 逻辑
+
Polymarket 结算是以**机场 METAR
+ 气象站**为准。在安卡拉等复杂城市,我们同时监控**市中心总站**(潜力热岛)和**机场站**(物理结算点)。当两者温差巨大时,往往预示着随后的补涨跳升机会。
+
+
+
⚠️ 风险偏置档案
+
系统记录了每个城市机场站与市区的距离、海拔差及季度性偏差。例如,首尔仁川机场因靠海,最高温通常比市区偏低;慕尼黑机场因海拔高,夜间降温极快。这些偏置已自动整合进 AI 分析逻辑中。
+
+
+
+
📈 未来日期概率分布
+
当你切换到未来 3-5 天时,系统会基于各模型对该日期的预报分歧度重新计算概率。分歧越大,分布越扁平(风险高);共识越强,分布越尖锐(机会明朗)。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/static/app.js b/frontend/public/static/app.js
new file mode 100644
index 00000000..fa4c329e
--- /dev/null
+++ b/frontend/public/static/app.js
@@ -0,0 +1,1399 @@
+/**
+ * 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. Handle Primary City Markers (Major vs Minor)
+ Object.values(markers).forEach(({ marker, city }) => {
+ const isMajor = city.is_major !== false;
+ // Hide minor cities (like Ankara/Atlanta) when zoomed way out
+ if (zoom < 4 && !isMajor) {
+ if (map.hasLayer(marker)) map.removeLayer(marker);
+ } else {
+ 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]) {
+ renderPanel(cityDataCache[cityName]);
+ renderNearbyStations(cityDataCache[cityName]);
+ 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 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/frontend/public/static/style.css b/frontend/public/static/style.css
new file mode 100644
index 00000000..3cc9db25
--- /dev/null
+++ b/frontend/public/static/style.css
@@ -0,0 +1,1289 @@
+/* ──────────────────────────────────────────────────────────
+ 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);
+}