feat: Implement initial web application with a Leaflet map, city weather details, and temperature trend visualization.

This commit is contained in:
2569718930@qq.com
2026-03-05 01:21:04 +08:00
parent 509049078b
commit 880aa2ca1e
5 changed files with 189 additions and 19 deletions
+1
View File
@@ -29,6 +29,7 @@ PolyWeather 是一款多模型气象分析与量化工具。它通过聚合高
- **数据可视化**Chart.js 温度走势图叠加 METAR 实测散点,多模型对比条形图,高斯概率分布条,实时风险等级色彩系统。
- **镜头与日期联动**:点击城市平滑飞入面板。**多模型预报区域**会根据“逐日预报”选中的日期自动刷新,支持查看未来 5 天各模型的历史表现与融合预测值。
- **强制同步与缓存控制**:针对安卡拉等重点区域,Web 端支持 60 秒极速缓存 TTL,并提供手动“强制刷新”按钮,可穿透全局缓存获取最及时的 MGM/METAR 实测数据。
- **周边测站热力图**:不仅追踪目标机场,还能并发抓取目标城市周边 20~50 公里内的所有气象站实测。通过地图上的“微型标签”可视化城市热岛效应与锋面推进,辅助判断冷空气过境的时间差。
- **双引擎共生**FastAPI 后端与 Telegram Bot 共享同一份分析逻辑(`analyze_weather_trend`)和 AI Prompt 管线。
### 2. 🧬 动态权重集合预报 (DEB 算法)
+104 -3
View File
@@ -591,6 +591,95 @@ class WeatherDataCollector:
logger.error(f"MGM API 请求失败 ({istno}): {e}")
return None
def fetch_mgm_nearby_stations(self, province: str) -> list:
"""
获取一个土耳其省份内所有气象站的当前温度及经纬度
使用多线程辅助抓取,因为直接通过 il={province} 往往只返回 1 个站。
"""
base_url = "https://servis.mgm.gov.tr/web"
headers = {
"Origin": "https://www.mgm.gov.tr",
"User-Agent": "Mozilla/5.0",
}
import time
from concurrent.futures import ThreadPoolExecutor
results = []
try:
# 1. 加载测站元数据 (缓存到实例中),用于过滤属于该省份的站点
if not getattr(self, "mgm_stations_meta", None):
meta_resp = self.session.get(f"{base_url}/istasyonlar", headers=headers, timeout=self.timeout)
if meta_resp.status_code == 200:
meta_json = meta_resp.json()
if isinstance(meta_json, list):
self.mgm_stations_meta = {s["istNo"]: s for s in meta_json if "istNo" in s}
else:
self.mgm_stations_meta = {}
metadata = getattr(self, "mgm_stations_meta", {})
# 2. 找出属于该省份的所有站点 istNo
province_upper = province.upper()
province_ist_nos = [
ist_no for ist_no, s in metadata.items()
if (s.get("il") or "").upper() == province_upper
]
if not province_ist_nos:
logger.warning(f"MGM 找不到省份 {province} 的站点元数据")
return []
# 为了性能,如果站点太多(比如安卡拉有50多个),可以只取前 25 个
# 或者根据重要性排序(如果元数据里有的话),目前先取前 25 个
target_ist_nos = province_ist_nos[:25]
# 3. 多线程获取每个站点的最新观测 (sondurumlar)
def fetch_single_station(ist_no):
try:
# sondurumlar?istno={ist_no} 是目前最稳的获取多站数据的办法
url = f"{base_url}/sondurumlar?istno={ist_no}&_={int(time.time() * 1000)}"
resp = self.session.get(url, headers=headers, timeout=5)
if resp.status_code == 200:
obs_list = resp.json()
if obs_list:
obs = obs_list[0] if isinstance(obs_list, list) else obs_list
temp = obs.get("sicaklik")
if temp is not None and temp > -9000:
return ist_no, temp
except:
pass
return None, None
# 并发抓取
station_temps = {}
with ThreadPoolExecutor(max_workers=10) as executor:
fetch_results = list(executor.map(fetch_single_station, target_ist_nos))
for ist_no, temp in fetch_results:
if ist_no is not None:
station_temps[ist_no] = temp
# 4. 组装最终结果
for ist_no, temp in station_temps.items():
meta = metadata[ist_no]
lat = meta.get("enlem")
lon = meta.get("boylam")
# 优先显示区县名,地图更清晰
display_name = (meta.get("ilce") or meta.get("istAd") or f"Station {ist_no}").title()
results.append({
"name": display_name,
"lat": lat,
"lon": lon,
"temp": temp,
"istNo": ist_no
})
logger.info(f"📍 MGM 周边测站: 成功并发抓取 {len(results)}{province} 站点的实时气温")
return results
except Exception as e:
logger.error(f"Failed to fetch MGM nearby stations for {province}: {e}")
return []
def fetch_nws(self, lat: float, lon: float) -> Optional[Dict]:
"""
从 NWS (美国国家气象局) 获取高精度预报
@@ -1219,11 +1308,23 @@ class WeatherDataCollector:
if metar_data:
results["metar"] = metar_data
# 对安卡拉,额外获取 MGM 官方数据 (17130 为 Ankara Bölge 市区测站)
if city_lower == "ankara":
mgm_data = self.fetch_from_mgm("17130")
# 对土耳其城市,额外获取 MGM 官方数据与周边测站
# 后面可以扩展更多土耳其城市,只需在这里添加映射
turkish_provinces = {
"ankara": ("17130", "Ankara"), # (主测站ID, 省份名用于周边)
# "istanbul": ("17060", "Istanbul"),
}
if city_lower in turkish_provinces:
istno, province = turkish_provinces[city_lower]
mgm_data = self.fetch_from_mgm(istno)
if mgm_data:
results["mgm"] = mgm_data
# 抓取并追加周边参考站数据 (并发模式)
nearby = self.fetch_mgm_nearby_stations(province)
if nearby:
results["mgm_nearby"] = nearby
# 对伦敦,获取 Meteoblue 预测 (公认最准)
if city_lower == "london":
+1
View File
@@ -476,6 +476,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
"wx_desc": mc.get("wx_desc"),
},
"mgm": mgm_data,
"mgm_nearby": raw.get("mgm_nearby", []),
"forecast": {
"today_high": om_today,
"daily": forecast_daily,
+69 -16
View File
@@ -39,6 +39,7 @@ let selectedCity = null;
let tempChart = null;
const AUTO_REFRESH_MS = 60 * 60 * 1000; // 1 hour
let selectedForecastDate = null;
let nearbyLayerGroup = null;
// ──────────────────────────────────────────────────────────
// Map Setup
@@ -64,6 +65,8 @@ function initMap() {
maxZoom: 19,
}).addTo(map);
nearbyLayerGroup = L.layerGroup().addTo(map);
// Close panel and clear selection when clicking on empty map space
map.on("click", () => {
closePanel();
@@ -229,6 +232,69 @@ async function fetchCityDetail(cityName, force = false) {
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";
const iconHtml = `
<div class="nearby-marker">
${st.name}: <span class="nearby-temp">${st.temp}</span><span class="nearby-unit">${sym}</span>
</div>
`;
const icon = L.divIcon({
html: iconHtml,
className: "",
iconSize: null,
iconAnchor: [-5, 5],
});
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
// ──────────────────────────────────────────────────────────
@@ -240,14 +306,7 @@ async function loadCityDetail(cityName, force = false) {
if (!force && cityDataCache[cityName]) {
renderPanel(cityDataCache[cityName]);
const cData = cityDataCache[cityName];
if (cData.lat != null && cData.lon != null) {
map.flyTo([cData.lat, cData.lon], 10, {
animate: true,
duration: 1.5,
easeLinearity: 0.25,
});
}
renderNearbyStations(cityDataCache[cityName]);
return;
}
@@ -259,14 +318,8 @@ async function loadCityDetail(cityName, force = false) {
saveCache();
renderPanel(data);
// Cinematic Zoom-in Camera
if (data.lat != null && data.lon != null) {
map.flyTo([data.lat, data.lon], 10, {
animate: true,
duration: 1.5,
easeLinearity: 0.25,
});
}
// Render nearby stations and zoom camera (cinematic or bounds)
renderNearbyStations(data);
// Update marker and list
if (data.current?.temp != null) {
+14
View File
@@ -823,6 +823,20 @@ body {
flex-shrink: 0;
}
/* ── Nearby Markers ── */
.nearby-marker {
background: rgba(30, 41, 59, 0.85); /* 暗夜蓝透明背景 */
color: #94a3b8; /* 灰白字体 */
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 2px 6px;
font-size: 11px; /* 比主站小 */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
white-space: nowrap;
backdrop-filter: blur(4px); /* 玻璃质感 */
pointer-events: none; /* Make them click-through to avoid interfering with map clicking unless tooltipped */
}
/* ── Custom Map Markers ── */
.city-marker {
position: relative;