feat: Implement initial web application with a Leaflet map, city weather details, and temperature trend visualization.
This commit is contained in:
@@ -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 算法)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user