/** * 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]) { 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}
${pct}%
`; }); container.innerHTML = html; // Animate bars requestAnimationFrame(() => { container.querySelectorAll(".prob-bar-fill").forEach((bar, i) => { const pct = Math.round(probs[i].probability * 100); bar.style.width = Math.max(pct, 8) + "%"; }); }); } function renderModels(data) { const container = document.getElementById("modelBars"); const 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(); });