1600 lines
60 KiB
HTML
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.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<script src="/static/js/chart.js"></script>
<!-- OpenLayers CSS and JS -->
<link rel="stylesheet" href="/static/css/ol.css">
<script src="/static/js/ol.js"></script>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
.header {
padding: 10px;
text-align: center;
border-bottom: 1px solid #ddd;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 15px;
}
.controls {
display: flex;
flex-direction: column;
gap: 15px;
margin-bottom: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #fff;
}
.control-row {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.control-group {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
.station-input-group {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
#stationInput {
width: 120px;
padding: 5px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: monospace;
font-size: 14px;
}
select, input, button {
padding: 5px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
background-color: #007bff;
color: white;
border: none;
cursor: pointer;
transition: background-color 0.3s;
}
button:hover {
background-color: #0056b3;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.map-container {
height: 60vh;
margin-bottom: 20px;
border: 1px solid #ddd;
border-radius: 4px;
position: relative;
transition: height 0.3s ease;
overflow: hidden;
}
.map-container.collapsed {
height: 20vh;
}
#map {
width: 100%;
height: 100%;
}
.map-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 5px;
}
.map-control-btn {
padding: 3px 8px;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
color: #666;
}
.map-control-btn:hover {
background-color: white;
}
.map-control-btn.active {
background-color: #007bff;
color: white;
}
.map-toggle-btn {
position: absolute;
top: 10px;
right: 10px;
z-index: 1001;
border-radius: 4px;
padding: 5px 10px;
cursor: pointer;
font-size: 12px;
font-weight: bold;
color: white;
background-color: #007bff;
}
.station-info-title {
text-align: center;
margin-bottom: 15px;
padding: 10px;
font-size: 14px;
line-height: 1.6;
}
.chart-container {
margin-bottom: 20px;
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
background-color: #fff;
display: none;
}
.chart-container.show {
display: block;
animation: slideDown 0.3s ease;
}
.chart-wrapper {
height: 500px;
margin-bottom: 30px;
}
.table-container {
overflow-x: auto;
margin-top: 20px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #fff;
display: none;
}
.table-container.show {
display: block;
animation: slideDown 0.3s ease;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
border: 1px solid #ddd;
padding: 12px 8px;
text-align: center;
}
th {
background-color: #f8f9fa;
font-weight: bold;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
tr:hover {
background-color: #f5f5f5;
}
.system-info {
background-color: #e9ecef;
padding: 10px;
margin-bottom: 20px;
border-radius: 4px;
font-size: 14px;
}
/* 设备列表样式 */
.device-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.3);
z-index: 2000;
}
.device-modal-content {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
height: 40vh;
width: 100%;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
}
.device-list-header {
padding: 15px 20px;
border-bottom: 1px solid #eee;
position: relative;
flex-shrink: 0;
}
.device-list {
flex: 1;
overflow-y: auto;
padding: 8px 0;
position: relative;
}
.device-list-footer {
padding: 10px;
border-top: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
}
.pagination {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: #666;
}
.pagination-btn {
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 3px;
background: #fff;
cursor: pointer;
font-size: 12px;
color: #666;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.device-item {
padding: 12px 20px;
border-bottom: 1px solid #f5f5f5;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.device-item:hover {
background-color: #fafafa;
}
.device-item:last-child {
border-bottom: none;
}
.close-modal {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #f5f5f5;
cursor: pointer;
color: #666;
font-size: 18px;
line-height: 1;
}
.close-modal:hover {
background-color: #eee;
color: #333;
}
.error {
color: #dc3545;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
padding: 10px;
margin: 10px 0;
}
.station-marker {
width: 24px;
height: 24px;
background-color: #007bff;
border: 2px solid white;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.station-marker.offline {
background-color: #6c757d;
}
.station-label {
background-color: rgba(255, 255, 255, 0.8);
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #007bff;
font-size: 12px;
white-space: nowrap;
pointer-events: none;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.controls {
flex-direction: column;
}
.control-group {
width: 100%;
}
select, input {
width: 100%;
}
.map-container {
height: 50vh;
}
}
</style>
</head>
<body>
<div class="header">
<h1>{{.Title}}</h1>
</div>
<!-- 设备列表 -->
<div id="deviceModal" class="device-modal">
<div class="device-modal-content">
<div class="device-list-header">
设备列表
<span class="close-modal">×</span>
</div>
<div id="deviceList" class="device-list">
<!-- 设备列表将通过JavaScript动态填充 -->
</div>
<div class="device-list-footer">
<div class="pagination">
<button class="pagination-btn" id="prevPage" disabled>&lt; 上一页</button>
<span><span id="currentPage">1</span> 页,共 <span id="totalPages">1</span></span>
<button class="pagination-btn" id="nextPage" disabled>下一页 &gt;</button>
</div>
</div>
</div>
</div>
<div class="container">
<!-- 系统信息 -->
<div class="system-info">
<strong>在线设备: </strong> <span id="onlineDevices">{{.OnlineDevices}}</span> 个 |
<strong>总设备: </strong> <a href="#" id="showDeviceList" style="color: #007bff; text-decoration: none;"><span id="wh65lpCount">0</span></a>
</div>
<!-- 控制面板 -->
<div class="controls">
<div class="control-row">
<div class="station-input-group">
<label for="stationInput">站点编号:</label>
<input type="text" id="stationInput" placeholder="">
</div>
<div class="control-group">
<label for="mapType">地图类型:</label>
<select id="mapType" onchange="switchLayer(this.value)">
<option value="satellite">卫星图</option>
<option value="vector">矢量图</option>
<option value="terrain">地形图</option>
<option value="hybrid">混合地形图</option>
</select>
</div>
</div>
<div class="control-row">
<div class="control-group">
<label for="interval">数据粒度:</label>
<select id="interval">
<option value="10min">10分钟</option>
<option value="30min">30分钟</option>
<option value="1hour" selected>1小时</option>
</select>
</div>
<div class="control-group">
<label for="forecastProvider">预报源:</label>
<select id="forecastProvider">
<option value="">不显示预报</option>
<option value="open-meteo" selected>Open-Meteo</option>
<option value="caiyun">彩云</option>
</select>
</div>
<div class="control-group">
<label for="startDate">开始时间:</label>
<input type="datetime-local" id="startDate">
</div>
<div class="control-group">
<label for="endDate">结束时间:</label>
<input type="datetime-local" id="endDate">
</div>
<div class="control-group">
<button onclick="queryHistoryData()" id="queryBtn">查看历史数据</button>
</div>
</div>
</div>
<!-- 地图容器 -->
<div class="map-container" id="mapContainer">
<div id="map"></div>
<button class="map-toggle-btn" id="toggleMapBtn" onclick="toggleMap()">折叠地图</button>
</div>
<!-- 图表容器 -->
<div class="chart-container" id="chartContainer">
<div id="stationInfoTitle" class="station-info-title"></div>
<div class="chart-wrapper">
<canvas id="combinedChart"></canvas>
</div>
</div>
<!-- 数据表格 -->
<div class="table-container" id="tableContainer">
<div id="forecastToggleContainer" style="padding: 8px 12px; font-size: 12px; color: #666; display: none; display: flex; justify-content: center; align-items: center;">
<label style="display: flex; align-items: center; gap: 5px;">
<input type="checkbox" id="showPastForecast" style="vertical-align: middle;">
显示历史预报
</label>
</div>
<table>
<thead>
<tr id="tableHeader">
<th>时间</th>
<th>温度 (°C)</th>
<th>湿度 (%)</th>
<th>气压 (hPa)</th>
<th>风速 (m/s)</th>
<th>风向 (°)</th>
<th>雨量 (mm)</th>
<th>光照 (lux)</th>
<th>紫外线</th>
</tr>
</thead>
<tbody id="tableBody">
<!-- 数据行将动态填充 -->
</tbody>
</table>
</div>
</div>
<script>
const TIANDITU_KEY = '{{.TiandituKey}}';
let map;
let stations = [];
let stationLayer;
let clusterLayer;
let clusterSource;
let singleStationLayer;
let combinedChart = null;
const CLUSTER_THRESHOLD = 10; // 缩放级别阈值,小于此值时启用集群
let isMapCollapsed = false; // 地图折叠状态
// 缓存最近一次查询数据,便于切换“显示历史预报”选项
let cachedHistoryData = [];
let cachedForecastData = [];
// 十六进制转十进制
function hexToDecimal(hex) {
return parseInt(hex, 16).toString();
}
// 十进制转十六进制保持6位不足补0
function decimalToHex(decimal) {
const hex = parseInt(decimal).toString(16).toUpperCase();
return '0'.repeat(Math.max(0, 6 - hex.length)) + hex;
}
// 地图图层
const layers = {
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=' + TIANDITU_KEY
})
}),
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=' + TIANDITU_KEY
})
})
]
}),
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=' + TIANDITU_KEY
})
}),
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=' + TIANDITU_KEY
})
})
],
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=' + TIANDITU_KEY
})
}),
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=' + TIANDITU_KEY
})
})
],
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=' + TIANDITU_KEY
})
}),
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=' + TIANDITU_KEY
})
})
],
visible: false
})
};
// 初始化页面
document.addEventListener('DOMContentLoaded', function() {
initializeDateInputs();
initializeMap();
loadStations();
// 每30秒刷新在线设备数量
setInterval(updateOnlineDevices, 30000);
// 监听"显示历史预报"复选框,切换表格渲染
const showPastForecast = document.getElementById('showPastForecast');
if (showPastForecast) {
showPastForecast.addEventListener('change', function() {
displayTable(cachedHistoryData, cachedForecastData);
});
}
// 添加输入框事件监听
const stationInput = document.getElementById('stationInput');
stationInput.addEventListener('input', function(e) {
// 允许输入数字和十六进制字符
this.value = this.value.toUpperCase().replace(/[^0-9A-F]/g, '');
});
// 设备列表模态框相关事件
const modal = document.getElementById('deviceModal');
const showDeviceListBtn = document.getElementById('showDeviceList');
const closeBtn = document.querySelector('.close-modal');
const prevPageBtn = document.getElementById('prevPage');
const nextPageBtn = document.getElementById('nextPage');
// 点击显示设备列表
showDeviceListBtn.addEventListener('click', function(e) {
e.preventDefault();
modal.style.display = 'block';
updateDeviceList(1);
});
// 分页按钮事件
prevPageBtn.addEventListener('click', function() {
if (currentPage > 1) {
updateDeviceList(currentPage - 1);
}
});
nextPageBtn.addEventListener('click', function() {
const totalPages = Math.ceil(filteredDevices.length / itemsPerPage);
if (currentPage < totalPages) {
updateDeviceList(currentPage + 1);
}
});
// 处理ID输入
stationInput.addEventListener('change', function() {
const value = this.value.trim();
if (value) {
if (isHexString(value)) {
// 如果是十六进制,转换为十进制
if (value.length <= 6) {
this.value = hexToDecimal(value);
}
} else {
// 如果是十进制,保持不变
const num = parseInt(value);
if (!isNaN(num)) {
this.value = num.toString();
}
}
}
});
// 点击关闭按钮
closeBtn.addEventListener('click', function() {
modal.style.display = 'none';
});
// 点击模态框外部关闭
window.addEventListener('click', function(e) {
if (e.target === modal) {
modal.style.display = 'none';
}
});
// 点击设备项自动填充并查询
document.getElementById('deviceList').addEventListener('click', function(e) {
const deviceItem = e.target.closest('.device-item');
if (deviceItem) {
const decimalId = deviceItem.getAttribute('data-decimal-id');
document.getElementById('stationInput').value = decimalId;
modal.style.display = 'none';
queryHistoryData();
}
});
});
// 初始化日期输入
function initializeDateInputs() {
const now = new Date();
//const startDate = new Date(now.getTime() - 48 * 60 * 60 * 1000); // 过去48小时
const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 过去48小时
const endDate = new Date(now.getTime() + 3 * 60 * 60 * 1000); // 未来3小时
document.getElementById('startDate').value = formatDatetimeLocal(startDate);
document.getElementById('endDate').value = formatDatetimeLocal(endDate);
}
function formatDatetimeLocal(date) {
const offset = date.getTimezoneOffset();
const localDate = new Date(date.getTime() - offset * 60 * 1000);
return localDate.toISOString().slice(0, 16);
}
// 初始化地图
function initializeMap() {
// 创建站点图层
stationLayer = new ol.layer.Vector({
source: new ol.source.Vector()
});
// 创建集群源和图层
clusterSource = new ol.source.Cluster({
distance: 60, // 默认集群距离
minDistance: 20, // 最小集群距离
source: stationLayer.getSource(),
geometryFunction: function(feature) {
// 使用原始几何形状进行聚类
return feature.getGeometry();
}
});
clusterLayer = new ol.layer.Vector({
source: clusterSource,
style: function(feature) {
const size = feature.get('features').length;
const zoom = map.getView().getZoom();
// 低缩放级别:显示集群
if (zoom < CLUSTER_THRESHOLD) {
if (size > 1) {
// 使用改进的集群样式
return createClusterStyle(size, true);
}
// 单点在低缩放级别显示为简化标记
return new ol.style.Style({
image: new ol.style.Circle({
radius: 6, // 稍大一些的点
fill: new ol.style.Fill({
color: new Date(feature.get('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 createStationStyle(feature.get('features')[0]);
}
// 高缩放级别的集群
return createClusterStyle(size, false);
}
});
// 创建单点图层(用于高缩放级别)
singleStationLayer = new ol.layer.Vector({
source: stationLayer.getSource(),
style: function(feature) {
return createStationStyle(feature);
},
visible: false
});
// 创建地图
map = new ol.Map({
target: 'map',
layers: [
layers.satellite,
layers.vector,
layers.terrain,
layers.hybrid,
clusterLayer,
singleStationLayer
],
view: new ol.View({
center: ol.proj.fromLonLat([108, 35]), // 中国中心
zoom: 5,
minZoom: 3,
maxZoom: 18
})
});
// 初始化时根据当前缩放设置集群距离和图层可见性
const initialZoom = map.getView().getZoom();
updateClusterDistance(initialZoom);
// 设置初始图层可见性
if (initialZoom >= CLUSTER_THRESHOLD) {
clusterLayer.setVisible(false);
singleStationLayer.setVisible(true);
} else {
clusterLayer.setVisible(true);
singleStationLayer.setVisible(false);
}
// 监听缩放事件
map.getView().on('change:resolution', function() {
const zoom = map.getView().getZoom();
updateClusterDistance(zoom);
// 根据缩放级别切换图层显示
if (zoom >= CLUSTER_THRESHOLD) {
clusterLayer.setVisible(false);
singleStationLayer.setVisible(true);
} else {
clusterLayer.setVisible(true);
singleStationLayer.setVisible(false);
}
});
// 添加点击事件
map.on('click', function(event) {
const feature = map.forEachFeatureAtPixel(event.pixel, function(feature) {
return feature;
});
if (feature) {
const features = feature.get('features');
if (features && features.length > 1) {
// 点击集群,放大地图
const extent = ol.extent.createEmpty();
features.forEach(function(feature) {
ol.extent.extend(extent, feature.getGeometry().getExtent());
});
// 计算适当的缩放级别
const zoom = map.getView().getZoom();
const targetZoom = Math.min(
// 如果集群内点数较少,直接放大到显示单点的级别
features.length <= 5 ? CLUSTER_THRESHOLD : zoom + 2,
CLUSTER_THRESHOLD
);
map.getView().fit(extent, {
padding: [100, 100, 100, 100],
duration: 800,
maxZoom: targetZoom
});
} else {
// 单个站点
const singleFeature = features ? features[0] : feature;
const decimalId = singleFeature.get('decimalId');
if (decimalId) {
// 设置输入框值
document.getElementById('stationInput').value = decimalId;
// 高亮显示选中的站点
const center = singleFeature.getGeometry().getCoordinates();
const currentZoom = map.getView().getZoom();
// 如果缩放级别不够高,增加缩放
if (currentZoom < CLUSTER_THRESHOLD) {
map.getView().animate({
center: center,
zoom: CLUSTER_THRESHOLD,
duration: 500
});
}
// 如果是1小时粒度自动查询
if (document.getElementById('interval').value === '1hour') {
queryHistoryData();
}
}
}
}
});
// 只添加鼠标指针效果
map.on('pointermove', function(event) {
const pixel = map.getEventPixel(event.originalEvent);
const hit = map.hasFeatureAtPixel(pixel);
map.getTargetElement().style.cursor = hit ? 'pointer' : '';
});
}
// 切换地图图层
function switchLayer(layerType) {
Object.keys(layers).forEach(key => {
layers[key].setVisible(key === layerType);
});
document.querySelectorAll('.map-control-btn').forEach(btn => {
btn.classList.remove('active');
});
document.getElementById(layerType + 'Btn').classList.add('active');
}
// 加载站点数据
async function loadStations() {
try {
const response = await fetch('/api/stations');
stations = await response.json();
// 更新WH65LP设备数量
const wh65lpDevices = stations.filter(station => station.device_type === 'WH65LP');
document.getElementById('wh65lpCount').textContent = wh65lpDevices.length;
displayStationsOnMap();
} catch (error) {
console.error('加载站点失败:', error);
}
}
// 分页相关变量
let currentPage = 1;
const itemsPerPage = 10;
let filteredDevices = [];
// 更新设备列表
function updateDeviceList(page = 1) {
const deviceListContainer = document.getElementById('deviceList');
deviceListContainer.innerHTML = '';
// 筛选WH65LP设备并按在线状态排序
filteredDevices = stations
.filter(station => station.device_type === 'WH65LP')
.sort((a, b) => {
const aOnline = new Date(a.last_update) > new Date(Date.now() - 5*60*1000);
const bOnline = new Date(b.last_update) > new Date(Date.now() - 5*60*1000);
if (aOnline === bOnline) return 0;
return aOnline ? -1 : 1;
});
// 计算分页
const totalPages = Math.ceil(filteredDevices.length / itemsPerPage);
currentPage = Math.min(Math.max(1, page), totalPages);
// 更新分页按钮状态
document.getElementById('currentPage').textContent = currentPage;
document.getElementById('totalPages').textContent = totalPages;
document.getElementById('prevPage').disabled = currentPage <= 1;
document.getElementById('nextPage').disabled = currentPage >= totalPages;
// 获取当前页的设备
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const currentDevices = filteredDevices.slice(startIndex, endIndex);
currentDevices.forEach(device => {
const isOnline = new Date(device.last_update) > new Date(Date.now() - 5*60*1000);
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 (filteredDevices.length === 0) {
deviceListContainer.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">暂无WH65LP设备</div>';
}
}
// 十六进制和十进制转换函数
function isHexString(str) {
return /^[0-9A-F]+$/i.test(str);
}
function hexToDecimal(hex) {
return parseInt(hex, 16).toString();
}
function decimalToHex(decimal) {
const hex = parseInt(decimal).toString(16).toUpperCase();
return '0'.repeat(Math.max(0, 6 - hex.length)) + hex;
}
// 更新集群距离
function updateClusterDistance(zoom) {
// 动态调整聚合距离,让低缩放更容易聚合
let distance;
if (zoom < 5) distance = 120; // 国家级别视图,大范围聚合
else if (zoom < 7) distance = 90; // 省级别视图,较大范围聚合
else if (zoom < 9) distance = 60; // 市级别视图,中等范围聚合
else if (zoom < CLUSTER_THRESHOLD) distance = 40; // 县/区级别视图,小范围聚合
else distance = 0; // 高缩放级别,不聚合
clusterSource.setDistance(distance);
// 强制刷新集群
clusterSource.refresh();
// 强制刷新集群层样式
setTimeout(() => {
clusterLayer.changed();
// 同时刷新单点图层
singleStationLayer.changed();
}, 100);
}
// 创建站点样式使用本地SVG图标
const markerStyleCache = {};
function getMarkerIconStyle(isOnline) {
const key = isOnline ? 'online' : 'offline';
if (markerStyleCache[key]) return 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 // 适当缩小图标比例
})
});
markerStyleCache[key] = style;
return style;
}
// 创建集群样式
function createClusterStyle(size, isLowZoom) {
// 根据点数量动态调整大小,但整体缩小一些
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
})
});
}
function createStationStyle(feature) {
const isOnline = new Date(feature.get('lastUpdate')) > new Date(Date.now() - 5*60*1000);
// 根据缩放级别决定是否显示详细信息
const zoom = map ? map.getView().getZoom() : 10;
let labelText = '';
// 显示完整信息,但是单行显示
if (zoom >= CLUSTER_THRESHOLD - 2) {
labelText = `${feature.get('decimalId') || '未知'} | ${feature.get('name') || '未知'} | ${feature.get('location') || '未知'}`;
}
// 低缩放级别不显示文本
return new ol.style.Style({
image: 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 }) // 保留白色描边确保可读性
})
});
}
// 在地图上显示站点
function displayStationsOnMap() {
const source = stationLayer.getSource();
source.clear();
// 计算在线和离线设备
const now = Date.now();
const fiveMinutesAgo = now - 5 * 60 * 1000;
let onlineCount = 0;
let offlineCount = 0;
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(`已加载 ${stations.length} 个站点,在线: ${onlineCount},离线: ${offlineCount}`);
// 自动调整视图以适应所有站点
if (source.getFeatures().length > 0) {
// 如果只有一个站点,设置适当的缩放级别
if (source.getFeatures().length === 1) {
const feature = source.getFeatures()[0];
map.getView().setCenter(feature.getGeometry().getCoordinates());
map.getView().setZoom(12); // 单个站点时缩放级别较高
} else {
// 多个站点时,自动适应所有站点的范围
const extent = source.getExtent();
map.getView().fit(extent, {
padding: [50, 50, 50, 50],
maxZoom: 10
});
}
}
// 强制刷新图层
updateClusterDistance(map.getView().getZoom());
}
// 切换地图显示
function toggleMap() {
const mapContainer = document.getElementById('mapContainer');
const toggleBtn = document.getElementById('toggleMapBtn');
isMapCollapsed = !isMapCollapsed;
if (isMapCollapsed) {
mapContainer.classList.add('collapsed');
toggleBtn.textContent = '展开地图';
} else {
mapContainer.classList.remove('collapsed');
toggleBtn.textContent = '折叠地图';
}
// 重新调整地图大小
setTimeout(() => {
map.updateSize();
}, 300);
}
// 更新在线设备数量
async function updateOnlineDevices() {
try {
const response = await fetch('/api/system/status');
const data = await response.json();
document.getElementById('onlineDevices').textContent = data.online_devices;
} catch (error) {
console.error('更新在线设备数量失败:', error);
}
}
// 查询历史数据
async function 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 : [];
// 查询预报数据如果选择了预报提供商且为1小时粒度
let forecastData = [];
if (forecastProvider && interval === '1hour') {
try {
// 将十进制ID转换为站点ID格式
const hexID = decimalToHex(decimalId);
const stationID = `RS485-${hexID}`;
// 始终按用户选择的起止时间获取全量预报
let forecastParams;
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 (forecastError) {
console.warn('查询预报数据失败:', forecastError);
// 预报查询失败不影响历史数据显示
}
}
// 缓存本次结果,用于表格切换
cachedHistoryData = historyData;
cachedForecastData = forecastData;
if (historyData.length === 0 && forecastData.length === 0) {
alert('该时间段内无数据');
return;
}
// 查找当前选择的站点信息
const station = stations.find(s => s.decimal_id == decimalId);
// 更新站点信息标题
const stationInfoTitle = document.getElementById('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 (!isMapCollapsed) {
toggleMap();
}
displayChart(historyData, forecastData);
displayTable(historyData, forecastData);
// 显示图表和表格
const chartContainer = document.getElementById('chartContainer');
const tableContainer = document.getElementById('tableContainer');
chartContainer.classList.add('show');
tableContainer.classList.add('show');
// 平滑滚动到图表位置,添加延时确保图表完全加载
setTimeout(() => {
chartContainer.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}, 300);
} catch (error) {
console.error('查询数据失败:', error);
alert('查询数据失败: ' + error.message);
}
}
// 显示图表
function displayChart(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 historyLabels = historyData.map(item => item.date_time);
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 (combinedChart) combinedChart.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');
combinedChart = 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
}
}
}
});
}
// 显示数据表格(按时间倒序)
function displayTable(historyData = [], forecastData = []) {
const tbody = document.getElementById('tableBody');
tbody.innerHTML = '';
// 确保数据是数组
historyData = Array.isArray(historyData) ? historyData : [];
forecastData = Array.isArray(forecastData) ? forecastData : [];
// 计算将要展示的预报集合未来3小时 + 可选历史)
const nowTs = Date.now();
const future3hTs = nowTs + 3 * 60 * 60 * 1000;
const showPastForecast = document.getElementById('showPastForecast');
const shouldShowPast = !!(showPastForecast && showPastForecast.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 timesWithForecast = new Set(displayedForecast.map(f => f.date_time));
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');
// 清除旧的表头
thead.innerHTML = '';
// 添加固定列
const fixedHeaders = ['时间', '温度 (°C)', '湿度 (%)', '气压 (hPa)', '风速 (m/s)', '风向 (°)', '雨量 (mm)'];
fixedHeaders.forEach(text => {
const th = document.createElement('th');
th.textContent = text;
thead.appendChild(th);
});
// 如果有预报数据,添加降水概率列
if (hasForecast) {
const th = document.createElement('th');
th.textContent = '降水概率 (%)';
thead.appendChild(th);
}
// 添加剩余列
const remainingHeaders = ['光照 (lux)', '紫外线'];
remainingHeaders.forEach(text => {
const th = document.createElement('th');
th.textContent = text;
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));
allData.forEach(item => {
const row = document.createElement('tr');
// 预报数据使用不同的背景色
if (item.source === '预报') {
row.style.backgroundColor = 'rgba(255, 165, 0, 0.08)'; // 淡橘黄色背景
}
// 移除错误的覆盖逻辑,让实测数据正常显示
const overrideDash = false; // 不再覆盖实测数据为'-'
const fmt2 = v => (v === null || v === undefined ? '-' : Number(v).toFixed(2));
const fmt3 = v => (v === null || v === undefined ? '-' : Number(v).toFixed(3));
// 构建基础列
let columns = [
`<td>${item.date_time}${hasForecast ? ` <span style="font-size: 12px; color: ${item.source === '预报' ? '#ff8c00' : '#28a745'};">[${item.source}]</span>` : ''}</td>`,
`<td>${overrideDash ? '-' : fmt2(item.temperature)}</td>`,
`<td>${overrideDash ? '-' : fmt2(item.humidity)}</td>`,
`<td>${overrideDash ? '-' : fmt2(item.pressure)}</td>`,
`<td>${overrideDash ? '-' : fmt2(item.wind_speed)}</td>`,
`<td>${overrideDash ? '-' : fmt2(item.wind_direction)}</td>`,
`<td>${overrideDash ? '-' : 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>${overrideDash ? '-' : (item.light !== null && item.light !== undefined ? Number(item.light).toFixed(2) : '-')}</td>`,
`<td>${overrideDash ? '-' : (item.uv !== null && item.uv !== undefined ? Number(item.uv).toFixed(2) : '-')}</td>`
);
row.innerHTML = columns.join('');
tbody.appendChild(row);
});
}
</script>
</body>
</html>