feat: 优化前端页面架构
This commit is contained in:
parent
67ba5cf21c
commit
29a3e9305b
257
static/js/app.js
Normal file
257
static/js/app.js
Normal file
@ -0,0 +1,257 @@
|
||||
// 应用主控制器
|
||||
const WeatherApp = {
|
||||
cachedHistoryData: [],
|
||||
cachedForecastData: [],
|
||||
currentPage: 1,
|
||||
itemsPerPage: 10,
|
||||
filteredDevices: [],
|
||||
|
||||
init() {
|
||||
// 初始化日期
|
||||
WeatherUtils.initializeDateInputs();
|
||||
// 初始化地图
|
||||
WeatherMap.init(window.TIANDITU_KEY || '');
|
||||
WeatherMap.loadStations();
|
||||
// 定时刷新在线设备数
|
||||
setInterval(() => this.updateOnlineDevices(), 30000);
|
||||
// 绑定 UI
|
||||
this.bindUI();
|
||||
// 监听地图请求查询事件
|
||||
window.addEventListener('query-history-data', () => this.queryHistoryData());
|
||||
},
|
||||
|
||||
bindUI() {
|
||||
const stationInput = document.getElementById('stationInput');
|
||||
if (stationInput) {
|
||||
stationInput.addEventListener('input', function() {
|
||||
this.value = this.value.toUpperCase().replace(/[^0-9A-F]/g, '');
|
||||
});
|
||||
stationInput.addEventListener('change', function() {
|
||||
const value = this.value.trim();
|
||||
if (!value) return;
|
||||
if (/^[0-9A-F]+$/i.test(value)) {
|
||||
if (value.length <= 6) this.value = WeatherUtils.hexToDecimal(value);
|
||||
} else {
|
||||
const num = parseInt(value);
|
||||
if (!isNaN(num)) this.value = num.toString();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const showDeviceListBtn = document.getElementById('showDeviceList');
|
||||
const modal = document.getElementById('deviceModal');
|
||||
const closeBtn = document.querySelector('.close-modal');
|
||||
const prevPageBtn = document.getElementById('prevPage');
|
||||
const nextPageBtn = document.getElementById('nextPage');
|
||||
|
||||
if (showDeviceListBtn && modal) {
|
||||
showDeviceListBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
modal.style.display = 'block';
|
||||
this.updateDeviceList(1);
|
||||
});
|
||||
}
|
||||
if (prevPageBtn) {
|
||||
prevPageBtn.addEventListener('click', () => {
|
||||
if (this.currentPage > 1) this.updateDeviceList(this.currentPage - 1);
|
||||
});
|
||||
}
|
||||
if (nextPageBtn) {
|
||||
nextPageBtn.addEventListener('click', () => {
|
||||
const totalPages = Math.ceil(this.filteredDevices.length / this.itemsPerPage);
|
||||
if (this.currentPage < totalPages) this.updateDeviceList(this.currentPage + 1);
|
||||
});
|
||||
}
|
||||
if (closeBtn && modal) {
|
||||
closeBtn.addEventListener('click', () => modal.style.display = 'none');
|
||||
window.addEventListener('click', (e) => { if (e.target === modal) modal.style.display = 'none'; });
|
||||
}
|
||||
const deviceListEl = document.getElementById('deviceList');
|
||||
if (deviceListEl && modal) {
|
||||
deviceListEl.addEventListener('click', (e) => {
|
||||
const deviceItem = e.target.closest('.device-item');
|
||||
if (!deviceItem) return;
|
||||
const decimalId = deviceItem.getAttribute('data-decimal-id');
|
||||
const input = document.getElementById('stationInput');
|
||||
if (input) input.value = decimalId;
|
||||
modal.style.display = 'none';
|
||||
this.queryHistoryData();
|
||||
});
|
||||
}
|
||||
|
||||
const showPastForecast = document.getElementById('showPastForecast');
|
||||
if (showPastForecast) {
|
||||
showPastForecast.addEventListener('change', () => {
|
||||
WeatherTable.display(this.cachedHistoryData, this.cachedForecastData);
|
||||
});
|
||||
}
|
||||
|
||||
// 提供全局函数以兼容现有 HTML on* 绑定
|
||||
window.switchLayer = (type) => WeatherMap.switchLayer(type);
|
||||
window.toggleMap = () => WeatherMap.toggleMap();
|
||||
window.queryHistoryData = () => this.queryHistoryData();
|
||||
},
|
||||
|
||||
// 更新设备列表
|
||||
updateDeviceList(page = 1) {
|
||||
const deviceListContainer = document.getElementById('deviceList');
|
||||
if (!deviceListContainer) return;
|
||||
deviceListContainer.innerHTML = '';
|
||||
|
||||
this.filteredDevices = (WeatherMap.stations || [])
|
||||
.filter(station => station.device_type === 'WH65LP')
|
||||
.sort((a, b) => {
|
||||
const aOnline = WeatherUtils.isDeviceOnline(a.last_update);
|
||||
const bOnline = WeatherUtils.isDeviceOnline(b.last_update);
|
||||
if (aOnline === bOnline) return 0;
|
||||
return aOnline ? -1 : 1;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(this.filteredDevices.length / this.itemsPerPage) || 1;
|
||||
this.currentPage = Math.min(Math.max(1, page), totalPages);
|
||||
|
||||
const currentPageEl = document.getElementById('currentPage');
|
||||
const totalPagesEl = document.getElementById('totalPages');
|
||||
const prevBtn = document.getElementById('prevPage');
|
||||
const nextBtn = document.getElementById('nextPage');
|
||||
if (currentPageEl) currentPageEl.textContent = this.currentPage;
|
||||
if (totalPagesEl) totalPagesEl.textContent = totalPages;
|
||||
if (prevBtn) prevBtn.disabled = this.currentPage <= 1;
|
||||
if (nextBtn) nextBtn.disabled = this.currentPage >= totalPages;
|
||||
|
||||
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
|
||||
const endIndex = startIndex + this.itemsPerPage;
|
||||
const currentDevices = this.filteredDevices.slice(startIndex, endIndex);
|
||||
|
||||
currentDevices.forEach(device => {
|
||||
const isOnline = WeatherUtils.isDeviceOnline(device.last_update);
|
||||
const deviceItem = document.createElement('div');
|
||||
deviceItem.className = 'device-item';
|
||||
deviceItem.setAttribute('data-decimal-id', device.decimal_id);
|
||||
deviceItem.innerHTML = `
|
||||
<div style="font-size: 13px; color: #444">
|
||||
${device.decimal_id} | ${device.name} | ${device.location || '未知位置'}
|
||||
</div>
|
||||
<span style="color: ${isOnline ? '#28a745' : '#dc3545'}; font-size: 12px; padding: 2px 6px; background: ${isOnline ? '#f0f9f1' : '#fef5f5'}; border-radius: 3px">${isOnline ? '在线' : '离线'}</span>
|
||||
`;
|
||||
deviceListContainer.appendChild(deviceItem);
|
||||
});
|
||||
|
||||
if (this.filteredDevices.length === 0) {
|
||||
deviceListContainer.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">暂无WH65LP设备</div>';
|
||||
}
|
||||
},
|
||||
|
||||
async updateOnlineDevices() {
|
||||
try {
|
||||
const response = await fetch('/api/system/status');
|
||||
const data = await response.json();
|
||||
const onlineEl = document.getElementById('onlineDevices');
|
||||
if (onlineEl) onlineEl.textContent = data.online_devices;
|
||||
} catch (error) {
|
||||
console.error('更新在线设备数量失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async queryHistoryData() {
|
||||
const decimalId = (document.getElementById('stationInput')?.value || '').trim();
|
||||
if (!decimalId) {
|
||||
alert('请输入站点编号');
|
||||
return;
|
||||
}
|
||||
if (!/^\d+$/.test(decimalId)) {
|
||||
alert('请输入有效的十进制编号');
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = document.getElementById('startDate').value;
|
||||
const endTime = document.getElementById('endDate').value;
|
||||
const interval = document.getElementById('interval').value;
|
||||
const forecastProvider = document.getElementById('forecastProvider').value;
|
||||
if (!startTime || !endTime) {
|
||||
alert('请选择开始和结束时间');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const historyParams = new URLSearchParams({
|
||||
decimal_id: decimalId,
|
||||
start_time: startTime.replace('T', ' ') + ':00',
|
||||
end_time: endTime.replace('T', ' ') + ':00',
|
||||
interval: interval
|
||||
});
|
||||
const historyResponse = await fetch(`/api/data?${historyParams}`);
|
||||
if (!historyResponse.ok) throw new Error('查询历史数据失败');
|
||||
const responseData = await historyResponse.json();
|
||||
const historyData = Array.isArray(responseData) ? responseData : [];
|
||||
|
||||
let forecastData = [];
|
||||
if (forecastProvider && interval === '1hour') {
|
||||
try {
|
||||
const hexID = WeatherUtils.decimalToHex(decimalId);
|
||||
const stationID = `RS485-${hexID}`;
|
||||
const forecastParams = new URLSearchParams({
|
||||
station_id: stationID,
|
||||
from: startTime.replace('T', ' ') + ':00',
|
||||
to: endTime.replace('T', ' ') + ':00',
|
||||
provider: forecastProvider
|
||||
});
|
||||
const forecastResponse = await fetch(`/api/forecast?${forecastParams}`);
|
||||
if (forecastResponse.ok) {
|
||||
const responseData = await forecastResponse.json();
|
||||
forecastData = Array.isArray(responseData) ? responseData : [];
|
||||
console.log(`查询到 ${forecastData.length} 条预报数据`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('查询预报数据失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedHistoryData = historyData;
|
||||
this.cachedForecastData = forecastData;
|
||||
|
||||
if (historyData.length === 0 && forecastData.length === 0) {
|
||||
alert('该时间段内无数据');
|
||||
return;
|
||||
}
|
||||
|
||||
const station = (WeatherMap.stations || []).find(s => s.decimal_id == decimalId);
|
||||
const stationInfoTitle = document.getElementById('stationInfoTitle');
|
||||
if (stationInfoTitle) {
|
||||
if (station) {
|
||||
stationInfoTitle.innerHTML = `
|
||||
<strong>
|
||||
${station.location || '未知位置'} ·
|
||||
编号 ${decimalId} ·
|
||||
坐标 ${station.latitude ? station.latitude.toFixed(6) : '未知'}, ${station.longitude ? station.longitude.toFixed(6) : '未知'}
|
||||
</strong>
|
||||
`;
|
||||
} else {
|
||||
stationInfoTitle.innerHTML = `编号 ${decimalId}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!WeatherMap.isMapCollapsed) WeatherMap.toggleMap();
|
||||
|
||||
WeatherChart.display(historyData, forecastData);
|
||||
WeatherTable.display(historyData, forecastData);
|
||||
|
||||
const chartContainer = document.getElementById('chartContainer');
|
||||
const tableContainer = document.getElementById('tableContainer');
|
||||
if (chartContainer) chartContainer.classList.add('show');
|
||||
if (tableContainer) tableContainer.classList.add('show');
|
||||
setTimeout(() => {
|
||||
chartContainer?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 300);
|
||||
} catch (error) {
|
||||
console.error('查询数据失败:', error);
|
||||
alert('查询数据失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.WeatherApp = WeatherApp;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
WeatherApp.init();
|
||||
});
|
||||
49
static/js/utils.js
Normal file
49
static/js/utils.js
Normal file
@ -0,0 +1,49 @@
|
||||
// 工具函数
|
||||
const WeatherUtils = {
|
||||
// 格式化日期时间
|
||||
formatDatetimeLocal(date) {
|
||||
const offset = date.getTimezoneOffset();
|
||||
const localDate = new Date(date.getTime() - offset * 60 * 1000);
|
||||
return localDate.toISOString().slice(0, 16);
|
||||
},
|
||||
|
||||
// 十六进制转十进制
|
||||
hexToDecimal(hex) {
|
||||
return parseInt(hex, 16).toString();
|
||||
},
|
||||
|
||||
// 十进制转十六进制(保持6位,不足补0)
|
||||
decimalToHex(decimal) {
|
||||
const hex = parseInt(decimal).toString(16).toUpperCase();
|
||||
return '0'.repeat(Math.max(0, 6 - hex.length)) + hex;
|
||||
},
|
||||
|
||||
// 检查是否为十六进制字符串
|
||||
isHexString(str) {
|
||||
return /^[0-9A-F]+$/i.test(str);
|
||||
},
|
||||
|
||||
// 格式化数字(保留2位小数)
|
||||
formatNumber(value, decimals = 2) {
|
||||
if (value === null || value === undefined) return '-';
|
||||
return Number(value).toFixed(decimals);
|
||||
},
|
||||
|
||||
// 检查设备是否在线
|
||||
isDeviceOnline(lastUpdate) {
|
||||
return new Date(lastUpdate) > new Date(Date.now() - 5 * 60 * 1000);
|
||||
},
|
||||
|
||||
// 初始化日期输入
|
||||
initializeDateInputs() {
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 过去24小时
|
||||
const endDate = new Date(now.getTime() + 3 * 60 * 60 * 1000); // 未来3小时
|
||||
|
||||
document.getElementById('startDate').value = this.formatDatetimeLocal(startDate);
|
||||
document.getElementById('endDate').value = this.formatDatetimeLocal(endDate);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出工具对象
|
||||
window.WeatherUtils = WeatherUtils;
|
||||
444
static/js/weather-app.js
Normal file
444
static/js/weather-app.js
Normal file
@ -0,0 +1,444 @@
|
||||
// 地图相关功能
|
||||
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();
|
||||
},
|
||||
|
||||
// 初始化图层
|
||||
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.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);
|
||||
},
|
||||
|
||||
// 创建地图图层
|
||||
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
|
||||
})
|
||||
};
|
||||
},
|
||||
|
||||
// 设置事件监听
|
||||
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;
|
||||
163
static/js/weather-chart.js
Normal file
163
static/js/weather-chart.js
Normal file
@ -0,0 +1,163 @@
|
||||
// 图表相关功能
|
||||
const WeatherChart = {
|
||||
chart: null,
|
||||
|
||||
// 显示图表
|
||||
display(historyData = [], forecastData = []) {
|
||||
// 确保数据是数组
|
||||
historyData = Array.isArray(historyData) ? historyData : [];
|
||||
forecastData = Array.isArray(forecastData) ? forecastData : [];
|
||||
|
||||
// 如果没有任何数据,则不绘制图表
|
||||
if (historyData.length === 0 && forecastData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 合并历史数据和预报数据的时间轴
|
||||
const allLabels = [...new Set([
|
||||
...historyData.map(item => item.date_time),
|
||||
...forecastData.map(item => item.date_time)
|
||||
])].sort();
|
||||
|
||||
// 准备历史数据
|
||||
const historyTemperatures = allLabels.map(label => {
|
||||
const item = historyData.find(d => d.date_time === label);
|
||||
return item ? item.temperature : null;
|
||||
});
|
||||
const historyHumidities = allLabels.map(label => {
|
||||
const item = historyData.find(d => d.date_time === label);
|
||||
return item ? item.humidity : null;
|
||||
});
|
||||
const historyRainfalls = allLabels.map(label => {
|
||||
const item = historyData.find(d => d.date_time === label);
|
||||
return item ? item.rainfall : null;
|
||||
});
|
||||
|
||||
// 准备预报数据
|
||||
const forecastTemperatures = allLabels.map(label => {
|
||||
const item = forecastData.find(d => d.date_time === label);
|
||||
return item && item.temperature !== null ? item.temperature : null;
|
||||
});
|
||||
const forecastRainfalls = allLabels.map(label => {
|
||||
const item = forecastData.find(d => d.date_time === label);
|
||||
return item && item.rainfall !== null ? item.rainfall : null;
|
||||
});
|
||||
|
||||
// 销毁旧图表
|
||||
if (this.chart) this.chart.destroy();
|
||||
|
||||
// 创建数据集
|
||||
const datasets = [
|
||||
{
|
||||
label: '温度 (°C) - 实测',
|
||||
data: historyTemperatures,
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
yAxisID: 'y-temperature',
|
||||
tension: 0.4,
|
||||
spanGaps: false
|
||||
},
|
||||
{
|
||||
label: '湿度 (%) - 实测',
|
||||
data: historyHumidities,
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||||
yAxisID: 'y-humidity',
|
||||
tension: 0.4,
|
||||
hidden: true,
|
||||
spanGaps: false
|
||||
},
|
||||
{
|
||||
label: '雨量 (mm) - 实测',
|
||||
data: historyRainfalls,
|
||||
type: 'bar',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.6)',
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
yAxisID: 'y-rainfall'
|
||||
}
|
||||
];
|
||||
|
||||
// 添加预报数据集
|
||||
if (forecastData.length > 0) {
|
||||
datasets.push(
|
||||
{
|
||||
label: '温度 (°C) - 预报',
|
||||
data: forecastTemperatures,
|
||||
borderColor: 'rgb(255, 165, 0)',
|
||||
backgroundColor: 'rgba(255, 165, 0, 0.1)',
|
||||
borderDash: [5, 5],
|
||||
yAxisID: 'y-temperature',
|
||||
tension: 0.4,
|
||||
spanGaps: false
|
||||
},
|
||||
{
|
||||
label: '雨量 (mm) - 预报',
|
||||
data: forecastRainfalls,
|
||||
type: 'bar',
|
||||
backgroundColor: 'rgba(255, 165, 0, 0.4)',
|
||||
borderColor: 'rgb(255, 165, 0)',
|
||||
yAxisID: 'y-rainfall'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 创建组合图表
|
||||
const ctx = document.getElementById('combinedChart').getContext('2d');
|
||||
this.chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: allLabels,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
'y-temperature': {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: '温度 (°C)'
|
||||
}
|
||||
},
|
||||
'y-humidity': {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: '湿度 (%)'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
},
|
||||
min: 0,
|
||||
max: 100
|
||||
},
|
||||
'y-rainfall': {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: '雨量 (mm)'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
},
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 导出图表对象
|
||||
window.WeatherChart = WeatherChart;
|
||||
103
static/js/weather-table.js
Normal file
103
static/js/weather-table.js
Normal file
@ -0,0 +1,103 @@
|
||||
// 表格渲染功能
|
||||
const WeatherTable = {
|
||||
display(historyData = [], forecastData = []) {
|
||||
const tbody = document.getElementById('tableBody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
|
||||
historyData = Array.isArray(historyData) ? historyData : [];
|
||||
forecastData = Array.isArray(forecastData) ? forecastData : [];
|
||||
|
||||
const nowTs = Date.now();
|
||||
const future3hTs = nowTs + 3 * 60 * 60 * 1000;
|
||||
const showPastForecastEl = document.getElementById('showPastForecast');
|
||||
const shouldShowPast = !!(showPastForecastEl && showPastForecastEl.checked);
|
||||
|
||||
const displayedForecast = forecastData.filter(item => {
|
||||
const t = new Date(item.date_time).getTime();
|
||||
const isFuture3h = t > nowTs && t <= future3hTs;
|
||||
const isPast = t <= nowTs;
|
||||
return isFuture3h || (shouldShowPast && isPast);
|
||||
});
|
||||
const hasForecast = displayedForecast.length > 0;
|
||||
|
||||
const forecastToggleContainer = document.getElementById('forecastToggleContainer');
|
||||
if (forecastToggleContainer) {
|
||||
forecastToggleContainer.style.display = forecastData.length > 0 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
const thead = document.getElementById('tableHeader');
|
||||
if (thead) {
|
||||
thead.innerHTML = '';
|
||||
const fixedHeaders = ['时间', '温度 (°C)', '湿度 (%)', '气压 (hPa)', '风速 (m/s)', '风向 (°)', '雨量 (mm)'];
|
||||
fixedHeaders.forEach(text => {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = text;
|
||||
th.className = 'bg-gray-50 font-semibold';
|
||||
thead.appendChild(th);
|
||||
});
|
||||
if (hasForecast) {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = '降水概率 (%)';
|
||||
th.className = 'bg-gray-50 font-semibold';
|
||||
thead.appendChild(th);
|
||||
}
|
||||
const remainingHeaders = ['光照 (lux)', '紫外线'];
|
||||
remainingHeaders.forEach(text => {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = text;
|
||||
th.className = 'bg-gray-50 font-semibold';
|
||||
thead.appendChild(th);
|
||||
});
|
||||
}
|
||||
|
||||
const allData = [];
|
||||
historyData.forEach(item => {
|
||||
allData.push({ ...item, source: '实测' });
|
||||
});
|
||||
displayedForecast.forEach(item => {
|
||||
allData.push({
|
||||
...item,
|
||||
source: '预报',
|
||||
light: null,
|
||||
wind_speed: item.wind_speed !== null ? item.wind_speed : 0,
|
||||
wind_direction: item.wind_direction !== null ? item.wind_direction : 0
|
||||
});
|
||||
});
|
||||
allData.sort((a, b) => new Date(b.date_time) - new Date(a.date_time));
|
||||
|
||||
const fmt2 = v => (v === null || v === undefined ? '-' : Number(v).toFixed(2));
|
||||
const fmt3 = v => (v === null || v === undefined ? '-' : Number(v).toFixed(3));
|
||||
|
||||
allData.forEach(item => {
|
||||
const row = document.createElement('tr');
|
||||
if (item.source === '预报') {
|
||||
row.style.backgroundColor = 'rgba(255, 165, 0, 0.08)';
|
||||
}
|
||||
|
||||
const columns = [
|
||||
`<td>${item.date_time}${hasForecast ? ` <span style="font-size: 12px; color: ${item.source === '预报' ? '#ff8c00' : '#28a745'};">[${item.source}]</span>` : ''}</td>`,
|
||||
`<td>${fmt2(item.temperature)}</td>`,
|
||||
`<td>${fmt2(item.humidity)}</td>`,
|
||||
`<td>${fmt2(item.pressure)}</td>`,
|
||||
`<td>${fmt2(item.wind_speed)}</td>`,
|
||||
`<td>${fmt2(item.wind_direction)}</td>`,
|
||||
`<td>${fmt3(item.rainfall)}</td>`
|
||||
];
|
||||
|
||||
if (hasForecast) {
|
||||
columns.push(`<td>${item.source === '预报' && item.precip_prob !== null && item.precip_prob !== undefined ? item.precip_prob : '-'}</td>`);
|
||||
}
|
||||
|
||||
columns.push(
|
||||
`<td>${(item.light !== null && item.light !== undefined) ? Number(item.light).toFixed(2) : '-'}</td>`,
|
||||
`<td>${(item.uv !== null && item.uv !== undefined) ? Number(item.uv).toFixed(2) : '-'}</td>`
|
||||
);
|
||||
|
||||
row.innerHTML = columns.join('');
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.WeatherTable = WeatherTable;
|
||||
1119
templates/index.html
1119
templates/index.html
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user