// 地图相关功能 const WeatherMap = { map: null, stations: [], stationLayer: null, clusterLayer: null, clusterSource: null, singleStationLayer: null, combinedChart: null, CLUSTER_THRESHOLD: 10, isMapCollapsed: false, // 初始化地图 init(tiandituKey) { this.initializeLayers(); this.initializeMap(tiandituKey); this.setupEventListeners(); this.setupTileControls(); // 默认叠加:组合反射率(radar),并准备默认索引 this.tileZ = 7; this.tileY = 40; this.tileX = 102; this.tileProduct = 'radar'; const prodSel = document.getElementById('tileProduct'); if (prodSel) prodSel.value = 'radar'; // 触发首次加载 try { this.reloadTileTimesAndShow(); } catch(e) { console.warn('init tiles load failed', e); } }, // 初始化图层 initializeLayers() { this.stationLayer = new ol.layer.Vector({ source: new ol.source.Vector() }); this.clusterSource = new ol.source.Cluster({ distance: 60, minDistance: 20, source: this.stationLayer.getSource(), geometryFunction: function(feature) { return feature.getGeometry(); } }); this.clusterLayer = new ol.layer.Vector({ source: this.clusterSource, style: (feature) => this.createClusterStyle(feature) }); this.singleStationLayer = new ol.layer.Vector({ source: this.stationLayer.getSource(), style: (feature) => this.createStationStyle(feature), visible: false }); }, // 初始化地图 initializeMap(tiandituKey) { const layers = this.createMapLayers(tiandituKey); this.layers = layers; this.map = new ol.Map({ target: 'map', layers: [ layers.satellite, layers.vector, layers.terrain, layers.hybrid, // 栅格瓦片叠加层(动态) this.createTileOverlayLayer(), this.clusterLayer, this.singleStationLayer ], view: new ol.View({ center: ol.proj.fromLonLat([108, 35]), zoom: 5, minZoom: 3, maxZoom: 18 }) }); // 初始化时设置图层状态 const initialZoom = this.map.getView().getZoom(); this.updateClusterDistance(initialZoom); this.updateLayerVisibility(initialZoom); }, createTileOverlayLayer(){ // 使用分组图层以支持同一时次叠加多块瓦片 const group = new ol.layer.Group({ layers: [], zIndex: 999, visible: true }); this.tileOverlayGroup = group; return group; }, // 创建地图图层 createMapLayers(tiandituKey) { return { satellite: new ol.layer.Group({ layers: [ new ol.layer.Tile({ source: new ol.source.XYZ({ url: `https://t{0-7}.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` }) }), new ol.layer.Tile({ source: new ol.source.XYZ({ url: `https://t{0-7}.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` }) }) ] }), vector: new ol.layer.Group({ layers: [ new ol.layer.Tile({ source: new ol.source.XYZ({ url: `https://t{0-7}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` }) }), new ol.layer.Tile({ source: new ol.source.XYZ({ url: `https://t{0-7}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` }) }) ], visible: false }), terrain: new ol.layer.Group({ layers: [ new ol.layer.Tile({ source: new ol.source.XYZ({ url: `https://t{0-7}.tianditu.gov.cn/ter_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ter&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` }) }), new ol.layer.Tile({ source: new ol.source.XYZ({ url: `https://t{0-7}.tianditu.gov.cn/cta_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cta&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` }) }) ], visible: false }), hybrid: new ol.layer.Group({ layers: [ new ol.layer.Tile({ source: new ol.source.XYZ({ url: `https://t{0-7}.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` }) }), new ol.layer.Tile({ source: new ol.source.XYZ({ url: `https://t{0-7}.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` }) }) ], visible: false }) }; }, // ---- 瓦片联动:控件与渲染 ---- setupTileControls(){ const prodSel = document.getElementById('tileProduct'); const timeSel = document.getElementById('tileTimeSelect'); const prevBtn = document.getElementById('btnTilePrev'); const nextBtn = document.getElementById('btnTileNext'); if (!prodSel || !timeSel || !prevBtn || !nextBtn) return; prodSel.addEventListener('change', async ()=>{ this.tileProduct = prodSel.value; // 切换产品:none 则隐藏图层 if (this.tileProduct === 'none') { this.tileTimes = []; this.tileCurrentIdx = -1; this.clearTileOverlays(); this.updateTileCountInfo(); return; } await this.reloadTileTimesAndShow(); }); const sEl = document.getElementById('startDate'); const eEl = document.getElementById('endDate'); const onRangeChange = async ()=>{ if (this.tileProduct && this.tileProduct !== 'none') { await this.reloadTileTimesAndShow(); } }; if (sEl) sEl.addEventListener('change', onRangeChange); if (eEl) eEl.addEventListener('change', onRangeChange); timeSel.addEventListener('change', async ()=>{ const dt = timeSel.value; if (!dt) return; const idx = (this.tileTimes||[]).indexOf(dt); if (idx >= 0) this.tileCurrentIdx = idx; await this.loadAndRenderTile(dt); this.updateTileCountInfo(); }); prevBtn.addEventListener('click', async ()=>{ if (!this.tileTimes || this.tileTimes.length === 0) return; // times 为倒序:上一时次=更老 => idx+1 if (this.tileCurrentIdx < this.tileTimes.length - 1) { this.tileCurrentIdx += 1; const dt = this.tileTimes[this.tileCurrentIdx]; timeSel.value = dt; await this.loadAndRenderTile(dt); this.updateTileCountInfo(); } }); nextBtn.addEventListener('click', async ()=>{ if (!this.tileTimes || this.tileTimes.length === 0) return; if (this.tileCurrentIdx > 0) { this.tileCurrentIdx -= 1; const dt = this.tileTimes[this.tileCurrentIdx]; timeSel.value = dt; await this.loadAndRenderTile(dt); this.updateTileCountInfo(); } }); }, async reloadTileTimesAndShow(){ try{ const times = await this.fetchTileTimes(this.tileProduct, this.tileZ, this.tileY, this.tileX); this.tileTimes = times || []; const timeSel = document.getElementById('tileTimeSelect'); if (timeSel){ timeSel.innerHTML = ''; if (this.tileTimes.length === 0){ const opt = document.createElement('option'); opt.value=''; opt.textContent='无可用时次'; timeSel.appendChild(opt); } else { this.tileTimes.forEach(dt=>{ const o=document.createElement('option'); o.value=dt; o.textContent=dt; timeSel.appendChild(o); }); this.tileCurrentIdx = 0; timeSel.value = this.tileTimes[0]; await this.loadAndRenderTile(this.tileTimes[0]); } this.updateTileCountInfo(); } }catch(e){ console.error('加载瓦片时次失败', e); } }, updateTileCountInfo(){ const el = document.getElementById('tileCountInfo'); if (!el) return; const total = this.tileTimes ? this.tileTimes.length : 0; const idxDisp = (this.tileCurrentIdx != null && this.tileCurrentIdx >= 0) ? (this.tileCurrentIdx+1) : 0; el.textContent = `共${total}条,第${idxDisp}条`; const prevBtn = document.getElementById('btnTilePrev'); const nextBtn = document.getElementById('btnTileNext'); if (prevBtn) prevBtn.disabled = !(total>0 && this.tileCurrentIdx < total-1); if (nextBtn) nextBtn.disabled = !(total>0 && this.tileCurrentIdx > 0); }, async fetchTileTimes(product, z,y,x){ const sEl = document.getElementById('startDate'); const eEl = document.getElementById('endDate'); let url; if (product==='rain') url = `/api/rain/times?z=${z}&y=${y}&x=${x}`; else url = `/api/radar/times?z=${z}&y=${y}&x=${x}`; if (sEl && eEl && sEl.value && eEl.value) { const from = sEl.value.replace('T',' ') + ':00'; const to = eEl.value.replace('T',' ') + ':00'; url += `&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`; } else { url += '&limit=60'; } const r = await fetch(url); if(!r.ok) return []; const j = await r.json(); return j.times || []; }, async loadAndRenderTile(dtStr){ try{ const z=this.tileZ,y=this.tileY,x=this.tileX; if (this.tileProduct === 'rain') { // 雨量:同一时次下叠加所有 y/x 瓦片 const url = `/api/rain/tiles_at?z=${z}&dt=${encodeURIComponent(dtStr)}`; const r = await fetch(url); if(!r.ok){ // 回退单块接口 const url1 = `/api/rain/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}`; const r1 = await fetch(url1); if(!r1.ok){ console.warn('雨量瓦片未找到', dtStr); return; } const t1 = await r1.json(); await this.renderTileOnMap('rain', t1); return; } const j = await r.json(); const tiles = Array.isArray(j.tiles) ? j.tiles : []; if (tiles.length === 0) { console.warn('该时次无雨量瓦片集合'); this.clearTileOverlays(); return; } await this.renderTilesOnMap('rain', tiles); } else { // radar: 取同一时次该 z 下的所有 y/x 瓦片 const url = `/api/radar/tiles_at?z=${z}&dt=${encodeURIComponent(dtStr)}`; const r = await fetch(url); if(!r.ok){ // 兼容后备:退回单块接口 const url1 = `/api/radar/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}`; const r1 = await fetch(url1); if(!r1.ok){ console.warn('雷达瓦片未找到', dtStr); return; } const t1 = await r1.json(); await this.renderTileOnMap('radar', t1); return; } const j = await r.json(); const tiles = Array.isArray(j.tiles) ? j.tiles : []; if (tiles.length === 0) { console.warn('该时次无雷达瓦片集合'); this.clearTileOverlays(); return; } await this.renderTilesOnMap('radar', tiles); } }catch(e){ console.error('加载/渲染瓦片失败', e); } }, clearTileOverlays(){ if (!this.tileOverlayGroup) return; // 清空子图层 const coll = this.tileOverlayGroup.getLayers(); if (coll) coll.clear(); this.tileLastList = []; }, addImageOverlayFromCanvas(canvas, extent4326){ const proj = this.map.getView().getProjection(); const extentProj = ol.proj.transformExtent(extent4326, 'EPSG:4326', proj); const dataURL = canvas.toDataURL('image/png'); const src = new ol.source.ImageStatic({ url: dataURL, imageExtent: extentProj, projection: proj }); const layer = new ol.layer.Image({ source: src, opacity: 0.8, visible: true }); this.tileOverlayGroup.getLayers().push(layer); }, renderTileOnMap(product, t){ if(!t || !t.values) return; const w=t.width, h=t.height, resDeg=t.res_deg; const west=t.west, south=t.south, east=t.east, north=t.north; // 生成画布 const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); const imgData = ctx.createImageData(w, h); // 色带 let colorFunc; if (product==='rain'){ const edges = [0,5,7.5,10,12.5,15,17.5,20,25,30,40,50,75,100, Infinity]; const colors = [ // 0值透明,(0,5) 用绿色,不再用白色 [126,212,121], [126,212,121], [110,200,109], [97,169,97], [81,148,76], [90,158,112], [143,194,254], [92,134,245], [66,87,240], [45,48,214], [26,15,166], [63,22,145], [191,70,148], [213,1,146], [213,1,146] ]; colorFunc = (mm)=>{ if (mm===0) return [0,0,0,0]; // 0 值透明 let idx=0; while(idx=edges[idx] && mm{ let v = Math.max(0, Math.min(75, dbz)); if (v===0) return [0,0,0,0]; // 0 值透明 let bin = Math.floor(v/5); if (bin>=colors.length) bin=colors.length-1; const c = colors[bin]; return [c[0],c[1],c[2],220]; }; } // API 行从南到北?我们按数组行序逐行,从上到下绘制需倒置行 let p=0; for(let row=0; row{ if (mm===0) return [0,0,0,0]; let idx=0; while(idx=edges[idx] && mm{ let v = Math.max(0, Math.min(75, dbz)); if (v===0) return [0,0,0,0]; let bin = Math.floor(v/5); if (bin>=colors.length) bin=colors.length-1; const c = colors[bin]; return [c[0],c[1],c[2],220]; }; } for(let row=0; row{ try{ // 需要至少一个叠加瓦片 if (!this.tileLastList || !this.tileOverlayGroup || this.tileOverlayGroup.getLayers().getLength()===0) { tip.style.display='none'; return; } const coord = this.map.getEventCoordinate(evt.originalEvent); const lonlat = ol.proj.transform(coord, this.map.getView().getProjection(), 'EPSG:4326'); const lon = lonlat[0], lat = lonlat[1]; // 选中包含该点的第一块瓦片 let pickedVal = null, pickedProd = null; for (const it of this.tileLastList){ const {west,south,east,north,resDeg,width,height} = it.meta; if (loneast || latnorth) continue; const col = Math.floor((lon - west)/resDeg); const row = Math.floor((lat - south)/resDeg); if (row<0 || row>=height || col<0 || col>=width) continue; const v = it.values[row]?.[col]; if (v==null) continue; pickedVal = Number(v); pickedProd = it.product; break; } if (pickedVal==null){ tip.style.display='none'; return; } const txt = pickedProd==='rain' ? `${pickedVal.toFixed(1)} mm` : `${pickedVal.toFixed(1)} dBZ`; tip.textContent = txt; // 位置 const pixel = this.map.getPixelFromCoordinate(coord); tip.style.left = (pixel[0]+10) + 'px'; tip.style.top = (pixel[1]+10) + 'px'; tip.style.display = 'block'; }catch{ tip.style.display='none'; } }); this._hoverBound = true; }, // 设置事件监听 setupEventListeners() { // 监听缩放事件 this.map.getView().on('change:resolution', () => { const zoom = this.map.getView().getZoom(); this.updateClusterDistance(zoom); this.updateLayerVisibility(zoom); }); // 添加点击事件 this.map.on('click', (event) => this.handleMapClick(event)); // 添加鼠标移动事件 this.map.on('pointermove', (event) => { const pixel = this.map.getEventPixel(event.originalEvent); const hit = this.map.hasFeatureAtPixel(pixel); this.map.getTargetElement().style.cursor = hit ? 'pointer' : ''; }); }, // 处理地图点击 handleMapClick(event) { const feature = this.map.forEachFeatureAtPixel(event.pixel, feature => feature); if (!feature) return; const features = feature.get('features'); if (features && features.length > 1) { this.handleClusterClick(features); } else { this.handleStationClick(features ? features[0] : feature); } }, // 处理集群点击 handleClusterClick(features) { const extent = ol.extent.createEmpty(); features.forEach(feature => { ol.extent.extend(extent, feature.getGeometry().getExtent()); }); const zoom = this.map.getView().getZoom(); const targetZoom = Math.min( features.length <= 5 ? this.CLUSTER_THRESHOLD : zoom + 2, this.CLUSTER_THRESHOLD ); this.map.getView().fit(extent, { padding: [100, 100, 100, 100], duration: 800, maxZoom: targetZoom }); }, // 处理站点点击 handleStationClick(feature) { const decimalId = feature.get('decimalId'); if (!decimalId) return; document.getElementById('stationInput').value = decimalId; const center = feature.getGeometry().getCoordinates(); const currentZoom = this.map.getView().getZoom(); if (currentZoom < this.CLUSTER_THRESHOLD) { this.map.getView().animate({ center: center, zoom: this.CLUSTER_THRESHOLD, duration: 500 }); } if (document.getElementById('interval').value === '1hour') { // 触发查询事件 window.dispatchEvent(new CustomEvent('query-history-data')); } }, // 更新集群距离 updateClusterDistance(zoom) { let distance; if (zoom < 5) distance = 120; else if (zoom < 7) distance = 90; else if (zoom < 9) distance = 60; else if (zoom < this.CLUSTER_THRESHOLD) distance = 40; else distance = 0; this.clusterSource.setDistance(distance); this.clusterSource.refresh(); setTimeout(() => { this.clusterLayer.changed(); this.singleStationLayer.changed(); }, 100); }, // 更新图层可见性 updateLayerVisibility(zoom) { if (zoom >= this.CLUSTER_THRESHOLD) { this.clusterLayer.setVisible(false); this.singleStationLayer.setVisible(true); } else { this.clusterLayer.setVisible(true); this.singleStationLayer.setVisible(false); } }, // 创建站点样式 createStationStyle(feature) { const isOnline = new Date(feature.get('lastUpdate')) > new Date(Date.now() - 5*60*1000); const zoom = this.map ? this.map.getView().getZoom() : 10; let labelText = ''; if (zoom >= this.CLUSTER_THRESHOLD - 2) { labelText = `${feature.get('decimalId') || '未知'} | ${feature.get('name') || '未知'} | ${feature.get('location') || '未知'}`; } return new ol.style.Style({ image: this.getMarkerIconStyle(isOnline).getImage(), text: new ol.style.Text({ text: labelText, font: '11px Arial', offsetY: -24, textAlign: 'center', textBaseline: 'bottom', fill: new ol.style.Fill({ color: '#666' }), stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) }) }); }, // 创建集群样式 createClusterStyle(feature) { const features = feature.get('features'); const size = features.length; const zoom = this.map.getView().getZoom(); if (zoom < this.CLUSTER_THRESHOLD) { if (size > 1) { const radius = Math.min(16 + size * 0.8, 32); const fontSize = Math.min(11 + size/12, 16); return new ol.style.Style({ image: new ol.style.Circle({ radius: radius, fill: new ol.style.Fill({ color: 'rgba(0, 123, 255, 0.8)' }), stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) }), text: new ol.style.Text({ text: String(size), fill: new ol.style.Fill({ color: '#fff' }), font: `bold ${fontSize}px Arial`, offsetY: 1 }) }); } return new ol.style.Style({ image: new ol.style.Circle({ radius: 6, fill: new ol.style.Fill({ color: new Date(features[0].get('lastUpdate')) > new Date(Date.now() - 5*60*1000) ? '#007bff' : '#6c757d' }), stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) }) }); } if (size === 1) { return this.createStationStyle(features[0]); } const radius = Math.min(16 + size * 0.8, 32); const fontSize = Math.min(11 + size/12, 16); return new ol.style.Style({ image: new ol.style.Circle({ radius: radius, fill: new ol.style.Fill({ color: 'rgba(0, 123, 255, 0.8)' }), stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) }), text: new ol.style.Text({ text: String(size), fill: new ol.style.Fill({ color: '#fff' }), font: `bold ${fontSize}px Arial`, offsetY: 1 }) }); }, // 获取标记图标样式 getMarkerIconStyle(isOnline) { const key = isOnline ? 'online' : 'offline'; if (this.markerStyleCache?.[key]) return this.markerStyleCache[key]; const iconPath = isOnline ? '/static/images/marker-online.svg' : '/static/images/marker-offline.svg'; const style = new ol.style.Style({ image: new ol.style.Icon({ src: iconPath, anchor: [0.5, 1], anchorXUnits: 'fraction', anchorYUnits: 'fraction', scale: 0.9 }) }); if (!this.markerStyleCache) this.markerStyleCache = {}; this.markerStyleCache[key] = style; return style; }, // 切换地图图层 switchLayer(layerType) { Object.keys(this.layers).forEach(key => { this.layers[key].setVisible(key === layerType); }); }, // 切换地图折叠状态 toggleMap() { this.isMapCollapsed = !this.isMapCollapsed; const mapContainer = document.getElementById('mapContainer'); const toggleBtn = document.getElementById('toggleMapBtn'); if (this.isMapCollapsed) { mapContainer.classList.add('collapsed'); toggleBtn.textContent = '展开地图'; } else { mapContainer.classList.remove('collapsed'); toggleBtn.textContent = '折叠地图'; } setTimeout(() => { this.map.updateSize(); }, 300); }, // 加载站点数据 async loadStations() { try { const response = await fetch('/api/stations'); this.stations = await response.json(); // 更新WH65LP设备数量 const wh65lpDevices = this.stations.filter(station => station.device_type === 'WH65LP'); document.getElementById('wh65lpCount').textContent = wh65lpDevices.length; this.displayStationsOnMap(); } catch (error) { console.error('加载站点失败:', error); } }, // 在地图上显示站点 displayStationsOnMap() { const source = this.stationLayer.getSource(); source.clear(); const now = Date.now(); const fiveMinutesAgo = now - 5 * 60 * 1000; let onlineCount = 0; let offlineCount = 0; this.stations.forEach(station => { if (station.latitude && station.longitude) { const isOnline = new Date(station.last_update) > new Date(fiveMinutesAgo); if (isOnline) onlineCount++; else offlineCount++; const feature = new ol.Feature({ geometry: new ol.geom.Point(ol.proj.fromLonLat([station.longitude, station.latitude])), stationId: station.station_id, decimalId: station.decimal_id, name: station.name, location: station.location, lastUpdate: station.last_update, isOnline: isOnline }); source.addFeature(feature); } }); console.log(`已加载 ${this.stations.length} 个站点,在线: ${onlineCount},离线: ${offlineCount}`); if (source.getFeatures().length > 0) { if (source.getFeatures().length === 1) { const feature = source.getFeatures()[0]; this.map.getView().setCenter(feature.getGeometry().getCoordinates()); this.map.getView().setZoom(12); } else { const extent = source.getExtent(); this.map.getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 10 }); } } this.updateClusterDistance(this.map.getView().getZoom()); } }; // 导出地图对象 window.WeatherMap = WeatherMap;