2025-08-10 01:13:39 +08:00

1323 lines
47 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: 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>&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="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>