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

676 lines
27 KiB
JavaScript
Raw 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,
// 初始化地图
init(tiandituKey) {
this.initializeLayers();
this.initializeMap(tiandituKey);
this.setupEventListeners();
this.setupTileControls();
// 默认不显示(需用户选择),但准备好默认索引
this.tileZ = 7; this.tileY = 40; this.tileX = 102;
this.tileProduct = 'none';
},
// 初始化图层
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 layer = new ol.layer.Image({
visible: true,
opacity: 0.8,
source: null,
zIndex: 999
});
this.tileOverlayLayer = layer;
return layer;
},
// 创建地图图层
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;
if (this.tileOverlayLayer) this.tileOverlayLayer.setSource(null);
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;
const url = this.tileProduct==='rain' ? `/api/rain/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}` : `/api/radar/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}`;
const r = await fetch(url);
if(!r.ok){ console.warn('瓦片未找到', dtStr); return; }
const t = await r.json();
await this.renderTileOnMap(this.tileProduct, t);
}catch(e){ console.error('加载/渲染瓦片失败', e); }
},
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 = [
[255,255,255], [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);
// 去除数值文本叠加(应用户要求)
const dataURL = canvas.toDataURL('image/png');
const extent4326 = [west, south, east, north];
const proj = this.map.getView().getProjection();
const extentProj = ol.proj.transformExtent(extent4326, 'EPSG:4326', proj);
const src = new ol.source.ImageStatic({ url: dataURL, imageExtent: extentProj, projection: proj });
this.tileOverlayLayer.setSource(src);
// 保存最近一次用于拾取
this.tileLast = { product, meta: { west, south, east, north, resDeg, width:w, height:h }, values: t.values };
this.setupTileHover();
},
setupTileHover(){
if (this._hoverBound) return;
const tip = document.getElementById('tileValueTooltip');
if (!tip) return;
this.map.on('pointermove', (evt)=>{
try{
if (!this.tileLast || !this.tileOverlayLayer || !this.tileOverlayLayer.getSource()) { 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 {west,south,east,north,resDeg} = this.tileLast.meta;
const lon = lonlat[0], lat = lonlat[1];
if (lon<west || lon>east || lat<south || lat>north) { tip.style.display='none'; return; }
const col = Math.floor((lon - west)/resDeg);
const row = Math.floor((lat - south)/resDeg);
if (row<0 || row>=this.tileLast.meta.height || col<0 || col>=this.tileLast.meta.width) { tip.style.display='none'; return; }
const v = this.tileLast.values[row]?.[col];
if (v==null) { tip.style.display='none'; return; }
const val = Number(v);
// 构建文本
const txt = this.tileLast.product==='rain' ? `${val.toFixed(1)} mm` : `${val.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;