786 lines
32 KiB
JavaScript
786 lines
32 KiB
JavaScript
// 地图相关功能
|
||
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.length-1 && !(mm>=edges[idx] && mm<edges[idx+1])) idx++;
|
||
const c = colors[Math.min(idx, colors.length-1)]; return [c[0],c[1],c[2],220];
|
||
};
|
||
} else {
|
||
// radar dBZ,0..75 映射 15 段
|
||
const colors = [[0,0,255],[0,191,255],[0,255,255],[127,255,212],[124,252,0],[173,255,47],[255,255,0],[255,215,0],[255,165,0],[255,140,0],[255,69,0],[255,0,0],[220,20,60],[199,21,133],[139,0,139]];
|
||
colorFunc = (dbz)=>{
|
||
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<h; row++){
|
||
const srcRow = t.values[row] || [];
|
||
const dstRow = (h-1-row); // 翻转以匹配北向上
|
||
for(let col=0; col<w; col++){
|
||
const v = srcRow[col];
|
||
let rgba;
|
||
if (v==null){ rgba=[0,0,0,0]; }
|
||
else { rgba = colorFunc(Number(v)); }
|
||
const off = (dstRow*w + col)*4;
|
||
imgData.data[off+0]=rgba[0];
|
||
imgData.data[off+1]=rgba[1];
|
||
imgData.data[off+2]=rgba[2];
|
||
imgData.data[off+3]=rgba[3];
|
||
}
|
||
}
|
||
ctx.putImageData(imgData, 0, 0);
|
||
// 清空并添加唯一图层
|
||
this.clearTileOverlays();
|
||
this.addImageOverlayFromCanvas(canvas, [west, south, east, north]);
|
||
// 保存用于拾取(单块)
|
||
this.tileLastList = [{ product, meta: { west, south, east, north, resDeg, width:w, height:h }, values: t.values }];
|
||
this.setupTileHover();
|
||
},
|
||
|
||
renderTilesOnMap(product, tiles){
|
||
// tiles: radarTileResponse[]
|
||
this.clearTileOverlays();
|
||
const lastList = [];
|
||
for (const t of tiles){
|
||
if(!t || !t.values) continue;
|
||
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];
|
||
let idx=0; while(idx<edges.length-1 && !(mm>=edges[idx] && mm<edges[idx+1])) idx++;
|
||
const c = colors[Math.min(idx, colors.length-1)]; return [c[0],c[1],c[2],220];
|
||
};
|
||
} else {
|
||
const colors = [[0,0,255],[0,191,255],[0,255,255],[127,255,212],[124,252,0],[173,255,47],[255,255,0],[255,215,0],[255,165,0],[255,140,0],[255,69,0],[255,0,0],[220,20,60],[199,21,133],[139,0,139]];
|
||
colorFunc = (dbz)=>{
|
||
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<h; row++){
|
||
const srcRow = t.values[row] || [];
|
||
const dstRow = (h-1-row);
|
||
for(let col=0; col<w; col++){
|
||
const v = srcRow[col];
|
||
let rgba;
|
||
if (v==null){ rgba=[0,0,0,0]; }
|
||
else { rgba = colorFunc(Number(v)); }
|
||
const off = (dstRow*w + col)*4;
|
||
imgData.data[off+0]=rgba[0];
|
||
imgData.data[off+1]=rgba[1];
|
||
imgData.data[off+2]=rgba[2];
|
||
imgData.data[off+3]=rgba[3];
|
||
}
|
||
}
|
||
ctx.putImageData(imgData, 0, 0);
|
||
this.addImageOverlayFromCanvas(canvas, [west, south, east, north]);
|
||
lastList.push({ product, meta: { west, south, east, north, resDeg, width:w, height:h }, values: t.values });
|
||
}
|
||
this.tileLastList = lastList;
|
||
this.setupTileHover();
|
||
},
|
||
|
||
setupTileHover(){
|
||
if (this._hoverBound) return;
|
||
const tip = document.getElementById('tileValueTooltip');
|
||
if (!tip) return;
|
||
this.map.on('pointermove', (evt)=>{
|
||
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 (lon<west || lon>east || lat<south || lat>north) 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;
|