1323 lines
47 KiB
HTML
1323 lines
47 KiB
HTML
<!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: 100px;
|
||
}
|
||
|
||
#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;
|
||
}
|
||
|
||
.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: left;
|
||
}
|
||
|
||
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>< 上一页</button>
|
||
<span>第 <span id="currentPage">1</span> 页,共 <span id="totalPages">1</span> 页</span>
|
||
<button class="pagination-btn" id="nextPage" disabled>下一页 ></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="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>
|
||
</div>
|
||
|
||
<!-- 图表容器 -->
|
||
<div class="chart-container" id="chartContainer">
|
||
<div class="chart-wrapper">
|
||
<canvas id="combinedChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 数据表格 -->
|
||
<div class="table-container" id="tableContainer">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<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; // 缩放级别阈值,小于此值时启用集群
|
||
|
||
// 十六进制转十进制
|
||
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 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 endDate = new Date(now);
|
||
const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24小时前
|
||
|
||
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;
|
||
|
||
if (!startTime || !endTime) {
|
||
alert('请选择开始和结束时间');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const params = new URLSearchParams({
|
||
decimal_id: decimalId,
|
||
start_time: startTime.replace('T', ' ') + ':00',
|
||
end_time: endTime.replace('T', ' ') + ':00',
|
||
interval: interval
|
||
});
|
||
|
||
const response = await fetch(`/api/data?${params}`);
|
||
if (!response.ok) {
|
||
throw new Error('查询失败');
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.length === 0) {
|
||
alert('该时间段内无数据');
|
||
return;
|
||
}
|
||
|
||
displayChart(data);
|
||
displayTable(data);
|
||
|
||
// 显示图表和表格
|
||
const chartContainer = document.getElementById('chartContainer');
|
||
const tableContainer = document.getElementById('tableContainer');
|
||
|
||
chartContainer.classList.add('show');
|
||
tableContainer.classList.add('show');
|
||
|
||
// 平滑滚动到图表位置
|
||
chartContainer.scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'start'
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('查询历史数据失败:', error);
|
||
alert('查询历史数据失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 显示图表
|
||
function displayChart(data) {
|
||
const labels = data.map(item => item.date_time);
|
||
const temperatures = data.map(item => item.temperature);
|
||
const humidities = data.map(item => item.humidity);
|
||
const rainfalls = data.map(item => item.rainfall);
|
||
|
||
// 销毁旧图表
|
||
if (combinedChart) combinedChart.destroy();
|
||
|
||
// 创建组合图表
|
||
const ctx = document.getElementById('combinedChart').getContext('2d');
|
||
combinedChart = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: labels,
|
||
datasets: [
|
||
{
|
||
label: '温度 (°C)',
|
||
data: temperatures,
|
||
borderColor: 'rgb(255, 99, 132)',
|
||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||
yAxisID: 'y-temperature',
|
||
tension: 0.4
|
||
},
|
||
{
|
||
label: '湿度 (%)',
|
||
data: humidities,
|
||
borderColor: 'rgb(54, 162, 235)',
|
||
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||
yAxisID: 'y-humidity',
|
||
tension: 0.4,
|
||
hidden: true // 默认隐藏湿度数据
|
||
},
|
||
{
|
||
label: '雨量 (mm)',
|
||
data: rainfalls,
|
||
type: 'bar',
|
||
backgroundColor: 'rgba(54, 162, 235, 0.6)',
|
||
borderColor: 'rgb(54, 162, 235)',
|
||
yAxisID: 'y-rainfall'
|
||
}
|
||
]
|
||
},
|
||
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(data) {
|
||
const tbody = document.getElementById('tableBody');
|
||
tbody.innerHTML = '';
|
||
|
||
const rows = [...data].reverse();
|
||
rows.forEach(item => {
|
||
const row = document.createElement('tr');
|
||
row.innerHTML = `
|
||
<td>${item.date_time}</td>
|
||
<td>${item.temperature.toFixed(2)}</td>
|
||
<td>${item.humidity.toFixed(2)}</td>
|
||
<td>${item.pressure.toFixed(2)}</td>
|
||
<td>${item.wind_speed.toFixed(2)}</td>
|
||
<td>${item.wind_direction.toFixed(2)}</td>
|
||
<td>${item.rainfall.toFixed(3)}</td>
|
||
<td>${item.light.toFixed(2)}</td>
|
||
<td>${item.uv.toFixed(2)}</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
});
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |