weather-station/static/js/weather-app.js

1075 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 地图相关功能
const WeatherMap = {
map: null,
stations: [],
stationLayer: null,
clusterLayer: null,
clusterSource: null,
singleStationLayer: null,
combinedChart: null,
CLUSTER_THRESHOLD: 10,
isMapCollapsed: false,
kmlLayers: [],
kmlLayerGroup: null,
activeKmlLayer: null,
kmlLastExtent: null,
kmlFitButton: null,
_kmlControlsBound: false,
kmlPopupEl: null,
// 初始化地图
init(tiandituKey) {
this.initializeLayers();
this.initializeMap(tiandituKey);
this.setupEventListeners();
this.setupTileControls();
this.setupKmlControls();
// 默认叠加组合反射率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
});
this.kmlLayerGroup = new ol.layer.Group({
layers: [],
zIndex: 1200,
visible: true
});
},
// 初始化地图
initializeMap(tiandituKey) {
const layers = this.createMapLayers(tiandituKey);
this.layers = layers;
const tileOverlayGroup = this.createTileOverlayLayer();
this.map = new ol.Map({
target: 'map',
layers: [
layers.satellite,
layers.vector,
layers.terrain,
layers.hybrid,
// 栅格瓦片叠加层(动态)
tileOverlayGroup,
this.kmlLayerGroup,
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);
// 取KML信息弹层元素
this.kmlPopupEl = document.getElementById('kmlInfoPopup');
},
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();
}
});
},
setupKmlControls(){
if (this._kmlControlsBound) return;
this._kmlControlsBound = true;
const select = document.getElementById('kmlLayerSelect');
const fitBtn = document.getElementById('btnKmlFit');
this.kmlFitButton = fitBtn || null;
this.kmlLayers = Array.isArray(window.KML_LAYERS) ? window.KML_LAYERS : [];
if (select) {
select.innerHTML = '';
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.textContent = '不显示';
select.appendChild(defaultOption);
if (this.kmlLayers.length === 0) {
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = '暂无KML图层';
select.appendChild(emptyOption);
select.disabled = true;
} else {
this.kmlLayers.forEach(layer => {
if (!layer || !layer.url) return;
const option = document.createElement('option');
option.value = layer.url;
option.textContent = layer.name || layer.url;
select.appendChild(option);
});
select.disabled = false;
}
select.addEventListener('change', (event)=>{
const url = event.target.value || '';
this.switchKmlLayer(url);
});
}
if (this.kmlFitButton) {
this.kmlFitButton.addEventListener('click', ()=>this.fitToKmlExtent());
}
this.updateKmlFitButton();
},
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 = [];
},
clearKmlLayer(){
if (!this.kmlLayerGroup) return;
const layers = this.kmlLayerGroup.getLayers();
if (layers) layers.clear();
this.activeKmlLayer = null;
this.kmlLastExtent = null;
this.updateKmlFitButton();
},
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 dBZ0..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;
},
switchKmlLayer(url){
this.clearKmlLayer();
if (!url || !this.map || !this.kmlLayerGroup) {
return;
}
const vectorSource = new ol.source.Vector({
url: url,
format: new ol.format.KML({
extractStyles: true,
showPointNames: true
}),
crossOrigin: 'anonymous'
});
const vectorLayer = new ol.layer.Vector({
source: vectorSource,
style: null,
visible: true,
opacity: 0.95,
zIndex: 1300
});
this.kmlLayerGroup.getLayers().push(vectorLayer);
this.activeKmlLayer = vectorLayer;
const handleSourceReady = ()=>{
if (vectorSource.getState && vectorSource.getState() !== 'ready') {
return;
}
vectorSource.un('change', handleSourceReady);
const extent = vectorSource.getExtent ? vectorSource.getExtent() : null;
if (extent && !ol.extent.isEmpty(extent)) {
this.kmlLastExtent = extent.slice ? extent.slice() : extent;
} else {
this.kmlLastExtent = null;
}
this.updateKmlFitButton();
if (this.kmlLastExtent) {
this.fitToKmlExtent({ duration: 600, padding: [80, 80, 80, 80] });
}
};
vectorSource.on('change', handleSourceReady);
vectorSource.on('error', ()=>{
console.error('KML图层加载失败:', url);
this.clearKmlLayer();
});
const computeExtentAndEnable = ()=>{
try{
const features = vectorSource.getFeatures ? vectorSource.getFeatures() : [];
if (!features || features.length === 0) {
return false;
}
let extent = ol.extent.createEmpty();
features.forEach((feature)=>{
const geom = feature && feature.getGeometry ? feature.getGeometry() : null;
if (geom) {
const geomExtent = geom.getExtent();
if (geomExtent) {
extent = ol.extent.extend(extent, geomExtent);
}
}
});
if (!extent || ol.extent.isEmpty(extent)) {
return false;
}
this.kmlLastExtent = extent.slice ? extent.slice() : extent;
this.updateKmlFitButton();
this.fitToKmlExtent({ duration: 600, padding: [80, 80, 80, 80] });
return true;
}catch(err){
console.warn('计算KML范围失败:', err);
return false;
}
};
const tryEnableWithDelay = (attempt = 0)=>{
if (computeExtentAndEnable()) return;
if (attempt > 10) return;
setTimeout(()=>tryEnableWithDelay(attempt+1), 120);
};
vectorSource.once('change', ()=>{
tryEnableWithDelay();
});
vectorSource.once('addfeature', ()=>{
tryEnableWithDelay();
});
vectorSource.once('featuresloadend', ()=>{
tryEnableWithDelay();
});
if (vectorSource.getState && vectorSource.getState() === 'ready') {
tryEnableWithDelay();
handleSourceReady();
}
},
updateKmlFitButton(){
if (!this.kmlFitButton) return;
this.kmlFitButton.disabled = !this.kmlLastExtent;
},
fitToKmlExtent(options = {}){
if (!this.map || !this.kmlLastExtent) return;
const view = this.map.getView();
if (!view) return;
const padding = Array.isArray(options.padding) ? options.padding : [60, 60, 60, 60];
const duration = typeof options.duration === 'number' ? options.duration : 500;
const maxZoom = view.getMaxZoom();
const targetMaxZoom = typeof maxZoom === 'number' ? Math.min(maxZoom, 17) : 17;
view.fit(this.kmlLastExtent, {
padding: padding,
duration: duration,
maxZoom: targetMaxZoom
});
},
// 设置事件监听
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 hit = this.map.forEachFeatureAtPixel(event.pixel, (f, layer) => ({ f, layer }));
if (!hit) { this.hideKmlPopup(); return; }
const feature = hit.f;
const layer = hit.layer;
const features = feature.get('features');
if (features && features.length > 1) {
this.hideKmlPopup();
this.handleClusterClick(features);
return;
}
// 如果命中KML图层要素则展示其信息
if (layer && this.activeKmlLayer && layer === this.activeKmlLayer) {
this.handleKmlFeatureClick(feature, event.coordinate, event.pixel);
return;
}
// 否则按站点要素处理
this.hideKmlPopup();
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());
},
// —— KML 要素点击展示 ——
handleKmlFeatureClick(feature, coordinate, pixel) {
if (!this.kmlPopupEl) return;
const html = this.buildKmlInfoHtml(feature);
this.showKmlPopup(html, pixel);
},
buildKmlInfoHtml(feature) {
const props = feature.getProperties ? feature.getProperties() : {};
const name = feature.get && (feature.get('name') || feature.get('Name')) || '';
const description = feature.get && (feature.get('description') || feature.get('Description')) || '';
// 复制并剔除不需要的字段
const displayProps = {};
Object.keys(props || {}).forEach(k => {
if (k === 'geometry' || k === 'name' || k === 'Name' || k === 'description' || k === 'Description' || k === 'styleUrl' || k === 'Style') return;
displayProps[k] = props[k];
});
let html = '';
if (name) {
html += `<div style="font-weight:600;margin-bottom:6px;">${this._escapeHtml(String(name))}</div>`;
}
if (description) {
// KML description 通常为HTML直接插入
html += `<div style="margin-bottom:6px;">${description}</div>`;
}
const keys = Object.keys(displayProps);
if (keys.length > 0) {
html += `<div style="max-height:180px;overflow:auto;"><table style="border-collapse:collapse;width:100%;">`;
keys.forEach(k => {
const v = displayProps[k];
const val = (v != null && typeof v === 'object') ? this._escapeHtml(JSON.stringify(v)) : this._escapeHtml(String(v));
html += `<tr><td style="border:1px solid #eee;padding:4px 6px;color:#666;white-space:nowrap;">${this._escapeHtml(k)}</td><td style="border:1px solid #eee;padding:4px 6px;color:#111;">${val}</td></tr>`;
});
html += `</table></div>`;
}
if (!name && !description && keys.length === 0) {
html = `<div style="color:#666;">该要素无可显示属性</div>`;
}
return html;
},
showKmlPopup(html, pixel) {
const el = this.kmlPopupEl;
if (!el) return;
el.innerHTML = html;
el.style.display = 'block';
const container = document.getElementById('mapContainer') || this.map.getTargetElement();
const cw = container.clientWidth;
const ch = container.clientHeight;
// 初步位置
const offsetX = 12, offsetY = 12;
let left = pixel[0] + offsetX;
let top = pixel[1] + offsetY;
// 尺寸与边界校正
const rect = el.getBoundingClientRect();
const pw = rect.width || 240;
const ph = rect.height || 120;
if (left + pw > cw - 8) left = Math.max(8, cw - pw - 8);
if (top + ph > ch - 8) top = Math.max(8, ch - ph - 8);
el.style.left = `${left}px`;
el.style.top = `${top}px`;
},
hideKmlPopup() {
const el = this.kmlPopupEl;
if (!el) return;
el.style.display = 'none';
},
_escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
};
// 导出地图对象
window.WeatherMap = WeatherMap;