Compare commits

..

2 Commits

Author SHA1 Message Date
ecf3a153f0 feat: 更新数据表气象数据存储类型 2025-08-08 10:39:43 +08:00
1c88bde080 feat: 新增地图 2025-08-08 10:07:50 +08:00
4 changed files with 623 additions and 228 deletions

View File

@ -4,6 +4,7 @@ import (
"database/sql"
"fmt"
"net/http"
"strconv"
"time"
"weatherstation/config"
@ -60,6 +61,7 @@ func indexHandler(c *gin.Context) {
Title: "英卓气象站",
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
OnlineDevices: getOnlineDevicesCount(),
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
}
c.HTML(http.StatusOK, "index.html", data)
}
@ -79,11 +81,15 @@ func getStationsHandler(c *gin.Context) {
SELECT DISTINCT s.station_id,
COALESCE(s.password, '') as station_name,
'WH65LP' as device_type,
COALESCE(MAX(r.timestamp), '1970-01-01'::timestamp) as last_update
COALESCE(MAX(r.timestamp), '1970-01-01'::timestamp) as last_update,
COALESCE(s.latitude, 0) as latitude,
COALESCE(s.longitude, 0) as longitude,
COALESCE(s.name, '') as name,
COALESCE(s.location, '') as location
FROM stations s
LEFT JOIN rs485_weather_data r ON s.station_id = r.station_id
WHERE s.station_id LIKE 'RS485-%'
GROUP BY s.station_id, s.password
GROUP BY s.station_id, s.password, s.latitude, s.longitude, s.name, s.location
ORDER BY s.station_id`
rows, err := ginDB.Query(query)
@ -97,98 +103,135 @@ func getStationsHandler(c *gin.Context) {
for rows.Next() {
var station Station
var lastUpdate time.Time
err := rows.Scan(&station.StationID, &station.StationName, &station.DeviceType, &lastUpdate)
err := rows.Scan(
&station.StationID,
&station.StationName,
&station.DeviceType,
&lastUpdate,
&station.Latitude,
&station.Longitude,
&station.Name,
&station.Location,
)
if err != nil {
continue
}
station.LastUpdate = lastUpdate.Format("2006-01-02 15:04:05")
// 从station_id中提取十六进制ID并转换为十进制
if len(station.StationID) > 6 {
hexID := station.StationID[len(station.StationID)-6:]
if decimalID, err := strconv.ParseInt(hexID, 16, 64); err == nil {
station.DecimalID = strconv.FormatInt(decimalID, 10)
}
}
stations = append(stations, station)
}
c.JSON(http.StatusOK, stations)
}
// 获取站点历史数据API
// 获取历史数据API
func getDataHandler(c *gin.Context) {
stationID := c.Query("station_id")
// 获取查询参数
decimalID := c.Query("decimal_id")
startTime := c.Query("start_time")
endTime := c.Query("end_time")
interval := c.DefaultQuery("interval", "1hour")
interval := c.Query("interval")
if stationID == "" || startTime == "" || endTime == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少必要参数"})
// 将十进制ID转换为十六进制补足6位
decimalNum, err := strconv.ParseInt(decimalID, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的站点编号"})
return
}
hexID := fmt.Sprintf("%06X", decimalNum)
stationID := fmt.Sprintf("RS485-%s", hexID)
var intervalSQL string
// 构建查询SQL
var query string
switch interval {
case "10min":
intervalSQL = "10 minutes"
case "30min":
intervalSQL = "30 minutes"
case "1hour":
intervalSQL = "1 hour"
default:
intervalSQL = "1 hour"
}
// 构建查询SQL - 使用时间窗口聚合
query := fmt.Sprintf(`
WITH time_series AS (
query = `
WITH grouped_data AS (
SELECT
date_trunc('hour', timestamp) +
INTERVAL '%s' * FLOOR(EXTRACT(EPOCH FROM timestamp - date_trunc('hour', timestamp)) / EXTRACT(EPOCH FROM INTERVAL '%s')) as time_bucket,
temperature,
humidity,
pressure,
wind_speed,
wind_direction,
rainfall,
light,
uv
(floor(date_part('minute', timestamp) / 10) * interval '10 minute') as time_group,
AVG(temperature) as temperature,
AVG(humidity) as humidity,
AVG(pressure) as pressure,
AVG(wind_speed) as wind_speed,
AVG(wind_direction) as wind_direction,
MAX(rainfall) - MIN(rainfall) as rainfall,
AVG(light) as light,
AVG(uv) as uv
FROM rs485_weather_data
WHERE station_id = $1
AND timestamp >= $2::timestamp
AND timestamp <= $3::timestamp
),
aggregated_data AS (
SELECT
time_bucket,
ROUND(AVG(temperature)::numeric, 2) as temperature,
ROUND(AVG(humidity)::numeric, 2) as humidity,
ROUND(AVG(pressure)::numeric, 2) as pressure,
ROUND(AVG(wind_speed)::numeric, 2) as wind_speed,
-- 风向使用矢量平均
ROUND(DEGREES(ATAN2(
AVG(SIN(RADIANS(wind_direction))),
AVG(COS(RADIANS(wind_direction)))
))::numeric + CASE
WHEN DEGREES(ATAN2(
AVG(SIN(RADIANS(wind_direction))),
AVG(COS(RADIANS(wind_direction)))
)) < 0 THEN 360
ELSE 0
END, 2) as wind_direction,
-- 雨量使用差值计算
ROUND((MAX(rainfall) - MIN(rainfall))::numeric, 3) as rainfall_diff,
ROUND(AVG(light)::numeric, 2) as light,
ROUND(AVG(uv)::numeric, 2) as uv
FROM time_series
GROUP BY time_bucket
WHERE station_id = $1 AND timestamp BETWEEN $2 AND $3
GROUP BY time_group
ORDER BY time_group
)
SELECT
time_bucket,
COALESCE(temperature, 0) as temperature,
COALESCE(humidity, 0) as humidity,
COALESCE(pressure, 0) as pressure,
COALESCE(wind_speed, 0) as wind_speed,
COALESCE(wind_direction, 0) as wind_direction,
COALESCE(rainfall_diff, 0) as rainfall,
COALESCE(light, 0) as light,
COALESCE(uv, 0) as uv
FROM aggregated_data
ORDER BY time_bucket`, intervalSQL, intervalSQL)
to_char(time_group, 'YYYY-MM-DD HH24:MI:SS') as date_time,
temperature, humidity, pressure, wind_speed, wind_direction,
CASE WHEN rainfall < 0 THEN 0 ELSE rainfall END as rainfall,
light, uv
FROM grouped_data
ORDER BY time_group`
case "30min":
query = `
WITH grouped_data AS (
SELECT
date_trunc('hour', timestamp) +
(floor(date_part('minute', timestamp) / 30) * interval '30 minute') as time_group,
AVG(temperature) as temperature,
AVG(humidity) as humidity,
AVG(pressure) as pressure,
AVG(wind_speed) as wind_speed,
AVG(wind_direction) as wind_direction,
MAX(rainfall) - MIN(rainfall) as rainfall,
AVG(light) as light,
AVG(uv) as uv
FROM rs485_weather_data
WHERE station_id = $1 AND timestamp BETWEEN $2 AND $3
GROUP BY time_group
ORDER BY time_group
)
SELECT
to_char(time_group, 'YYYY-MM-DD HH24:MI:SS') as date_time,
temperature, humidity, pressure, wind_speed, wind_direction,
CASE WHEN rainfall < 0 THEN 0 ELSE rainfall END as rainfall,
light, uv
FROM grouped_data
ORDER BY time_group`
default: // 1hour
query = `
WITH grouped_data AS (
SELECT
date_trunc('hour', timestamp) as time_group,
AVG(temperature) as temperature,
AVG(humidity) as humidity,
AVG(pressure) as pressure,
AVG(wind_speed) as wind_speed,
AVG(wind_direction) as wind_direction,
MAX(rainfall) - MIN(rainfall) as rainfall,
AVG(light) as light,
AVG(uv) as uv
FROM rs485_weather_data
WHERE station_id = $1 AND timestamp BETWEEN $2 AND $3
GROUP BY time_group
ORDER BY time_group
)
SELECT
to_char(time_group, 'YYYY-MM-DD HH24:MI:SS') as date_time,
temperature, humidity, pressure, wind_speed, wind_direction,
CASE WHEN rainfall < 0 THEN 0 ELSE rainfall END as rainfall,
light, uv
FROM grouped_data
ORDER BY time_group`
}
// 执行查询
rows, err := ginDB.Query(query, stationID, startTime, endTime)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询数据失败"})
@ -196,21 +239,27 @@ func getDataHandler(c *gin.Context) {
}
defer rows.Close()
var data []WeatherPoint
var weatherPoints []WeatherPoint
for rows.Next() {
var point WeatherPoint
var timestamp time.Time
err := rows.Scan(&timestamp, &point.Temperature, &point.Humidity,
&point.Pressure, &point.WindSpeed, &point.WindDir,
&point.Rainfall, &point.Light, &point.UV)
err := rows.Scan(
&point.DateTime,
&point.Temperature,
&point.Humidity,
&point.Pressure,
&point.WindSpeed,
&point.WindDir,
&point.Rainfall,
&point.Light,
&point.UV,
)
if err != nil {
continue
}
point.DateTime = timestamp.Format("2006-01-02 15:04:05")
data = append(data, point)
weatherPoints = append(weatherPoints, point)
}
c.JSON(http.StatusOK, data)
c.JSON(http.StatusOK, weatherPoints)
}
func StartGinServer() {

View File

@ -42,23 +42,35 @@ func InitDB() error {
func createRS485Table() error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS rs485_weather_data (
id SERIAL PRIMARY KEY,
id BIGSERIAL PRIMARY KEY,
station_id VARCHAR(50) NOT NULL,
timestamp TIMESTAMP NOT NULL,
temperature DECIMAL(5,2), -- 温度摄氏度
humidity DECIMAL(5,2), -- 湿度%
wind_speed DECIMAL(5,2), -- 风速m/s
wind_direction DECIMAL(5,2), -- 风向
rainfall DECIMAL(5,2), -- 降雨量mm
light DECIMAL(10,2), -- 光照lux
uv DECIMAL(5,2), -- 紫外线
pressure DECIMAL(7,2), -- 气压hPa
raw_data TEXT, -- 原始数据
FOREIGN KEY (station_id) REFERENCES stations(station_id)
timestamp TIMESTAMPTZ NOT NULL,
temperature DOUBLE PRECISION,
humidity DOUBLE PRECISION,
wind_speed DOUBLE PRECISION,
wind_direction DOUBLE PRECISION,
rainfall DOUBLE PRECISION,
light DOUBLE PRECISION,
uv DOUBLE PRECISION,
pressure DOUBLE PRECISION,
raw_data TEXT,
FOREIGN KEY (station_id) REFERENCES stations(station_id),
UNIQUE (station_id, timestamp)
)`)
if err != nil {
return err
}
// 支持性索引(若已存在则不重复创建)
if _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_rwd_time ON rs485_weather_data (timestamp)`); err != nil {
return err
}
if _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_rwd_station_time ON rs485_weather_data (station_id, timestamp)`); err != nil {
return err
}
return nil
}
func CloseDB() {
if db != nil {
db.Close()

View File

@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- OpenLayers CSS and JS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@7.5.2/ol.css">
<script src="https://cdn.jsdelivr.net/npm/ol@7.5.2/dist/ol.js"></script>
<style>
body {
font-family: Arial, sans-serif;
@ -24,57 +27,12 @@
padding: 15px;
}
.station-selection {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f8f9fa;
}
.station-card {
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
cursor: pointer;
transition: all 0.3s;
min-width: 150px;
}
.station-card:hover {
background-color: #e9ecef;
}
.station-card.selected {
background-color: #007bff;
color: white;
}
.station-name {
font-weight: bold;
font-size: 14px;
}
.station-id {
font-size: 12px;
color: #666;
margin-top: 2px;
}
.station-card.selected .station-id {
color: #cce7ff;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 20px;
padding: 10px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #fff;
@ -86,6 +44,21 @@
gap: 5px;
}
.station-input-group {
display: flex;
align-items: center;
gap: 5px;
}
#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;
@ -110,6 +83,53 @@
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: 5px 10px;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.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;
@ -121,6 +141,7 @@
.chart-container.show {
display: block;
animation: slideDown 0.3s ease;
}
.chart-wrapper {
@ -139,6 +160,7 @@
.table-container.show {
display: block;
animation: slideDown 0.3s ease;
}
table {
@ -165,10 +187,12 @@
background-color: #f5f5f5;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
.system-info {
background-color: #e9ecef;
padding: 10px;
margin-bottom: 20px;
border-radius: 4px;
font-size: 14px;
}
.error {
@ -180,12 +204,45 @@
margin: 10px 0;
}
.system-info {
background-color: #e9ecef;
padding: 10px;
margin-bottom: 20px;
.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;
font-size: 14px;
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) {
@ -200,6 +257,10 @@
select, input {
width: 100%;
}
.map-container {
height: 50vh;
}
}
</style>
</head>
@ -214,14 +275,22 @@
<strong>系统信息:</strong> 服务器时间: {{.ServerTime}} | 在线设备: <span id="onlineDevices">{{.OnlineDevices}}</span>
</div>
<!-- 站点选择 -->
<div class="station-selection">
<h3 style="width: 100%; margin: 0 0 10px 0;">请选择监测站点:</h3>
<div id="stationList" class="loading">正在加载站点信息...</div>
</div>
<!-- 控制面板 -->
<div class="controls">
<div class="station-input-group">
<label for="stationInput">站点编号:</label>
<input type="text" id="stationInput" placeholder="输入十进制编号10738" style="width: 200px;">
</div>
<div class="control-group">
<label for="mapType">地图类型:</label>
<select id="mapType" onchange="switchLayer(this.value)">
<option value="vector">矢量图</option>
<option value="terrain">地形图</option>
<option value="hybrid">混合地形图</option>
</select>
</div>
<div class="control-group">
<label for="interval">数据粒度:</label>
<select id="interval">
@ -242,11 +311,15 @@
</div>
<div class="control-group">
<button onclick="queryHistoryData()" id="queryBtn" disabled>查询历史数据</button>
<button onclick="refreshStations()" id="refreshBtn">刷新站点</button>
<button onclick="queryHistoryData()" id="queryBtn">查看历史数据</button>
</div>
</div>
<!-- 地图容器 -->
<div class="map-container" id="mapContainer">
<div id="map"></div>
</div>
<!-- 图表容器 -->
<div class="chart-container" id="chartContainer">
<div class="chart-wrapper">
@ -278,16 +351,89 @@
</div>
<script>
let selectedStation = null;
const TIANDITU_KEY = '{{.TiandituKey}}';
let map;
let stations = [];
let stationLayer;
let clusterLayer;
let clusterSource;
let combinedChart = null;
const CLUSTER_THRESHOLD = 11; // 缩放级别阈值,小于此值时启用集群
// 十六进制转十进制
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 = {
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
})
})
]
}),
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() {
loadStations();
initializeDateInputs();
initializeMap();
loadStations();
// 每30秒刷新在线设备数量
setInterval(updateOnlineDevices, 30000);
// 添加输入框事件监听
const stationInput = document.getElementById('stationInput');
stationInput.addEventListener('input', function(e) {
// 移除非数字字符
this.value = this.value.replace(/[^0-9]/g, '');
});
});
// 初始化日期输入
@ -306,6 +452,239 @@
return localDate.toISOString().slice(0, 16);
}
// 初始化地图
function initializeMap() {
// 创建站点图层
stationLayer = new ol.layer.Vector({
source: new ol.source.Vector()
});
// 创建集群源和图层
clusterSource = new ol.source.Cluster({
distance: 40,
source: stationLayer.getSource()
});
clusterLayer = new ol.layer.Vector({
source: clusterSource,
style: function(feature) {
const size = feature.get('features').length;
if (size === 1) {
// 单个站点样式
return createStationStyle(feature.get('features')[0]);
}
// 集群样式
return new ol.style.Style({
image: new ol.style.Circle({
radius: 15,
fill: new ol.style.Fill({
color: '#007bff'
}),
stroke: new ol.style.Stroke({
color: '#fff',
width: 2
})
}),
text: new ol.style.Text({
text: size.toString(),
fill: new ol.style.Fill({
color: '#fff'
}),
font: '12px Arial'
})
});
}
});
// 创建地图
map = new ol.Map({
target: 'map',
layers: [
layers.vector,
layers.terrain,
layers.hybrid,
clusterLayer
],
view: new ol.View({
center: ol.proj.fromLonLat([108, 35]), // 中国中心
zoom: 5,
minZoom: 3,
maxZoom: 18
})
});
// 监听缩放事件
map.getView().on('change:resolution', function() {
const zoom = map.getView().getZoom();
updateClusterDistance(zoom);
});
// 添加点击事件
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());
});
map.getView().fit(extent, {
padding: [50, 50, 50, 50],
duration: 1000,
maxZoom: CLUSTER_THRESHOLD
});
} else {
// 单个站点获取十进制ID
const singleFeature = features ? features[0] : feature;
const decimalId = singleFeature.get('decimalId');
if (decimalId) {
document.getElementById('stationInput').value = decimalId;
// 如果是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();
displayStationsOnMap();
} catch (error) {
console.error('加载站点失败:', error);
}
}
// 更新集群距离
function updateClusterDistance(zoom) {
const distance = zoom < CLUSTER_THRESHOLD ? 40 : 0;
clusterSource.setDistance(distance);
}
// 创建站点样式
function createStationStyle(feature) {
const isOnline = new Date(feature.get('lastUpdate')) > new Date(Date.now() - 5*60*1000);
const labelText = [
`十进制ID: ${feature.get('decimalId') || '未知'}`,
`十六进制ID: ${feature.get('stationId').split('-')[1] || '未知'}`,
`名称: ${feature.get('name') || '未知'}`,
`位置: ${feature.get('location') || '未知'}`
].join('\n');
return new ol.style.Style({
image: new ol.style.Circle({
radius: 6,
fill: new ol.style.Fill({
color: isOnline ? '#007bff' : '#6c757d'
}),
stroke: new ol.style.Stroke({
color: '#fff',
width: 2
})
}),
text: new ol.style.Text({
text: labelText,
font: '12px Arial',
offsetY: -30,
textAlign: 'left',
textBaseline: 'bottom',
fill: new ol.style.Fill({
color: '#333'
}),
stroke: new ol.style.Stroke({
color: '#fff',
width: 3
}),
padding: [5, 5, 5, 5],
backgroundFill: new ol.style.Fill({
color: 'rgba(255, 255, 255, 0.8)'
})
})
});
}
// 在地图上显示站点
function displayStationsOnMap() {
const source = stationLayer.getSource();
source.clear();
stations.forEach(station => {
if (station.latitude && station.longitude) {
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
});
source.addFeature(feature);
}
});
// 自动缩放到所有站点
if (source.getFeatures().length > 0) {
const extent = source.getExtent();
map.getView().fit(extent, {
padding: [50, 50, 50, 50],
duration: 1000
});
}
}
// 切换地图显示
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 {
@ -317,72 +696,17 @@
}
}
// 刷新站点列表
function refreshStations() {
loadStations();
}
// 加载站点列表
async function loadStations() {
try {
const response = await fetch('/api/stations');
const stations = await response.json();
const stationList = document.getElementById('stationList');
stationList.innerHTML = '';
if (stations.length === 0) {
stationList.innerHTML = '<div class="error">暂无WH65LP设备站点</div>';
// 查询历史数据
async function queryHistoryData() {
const decimalId = document.getElementById('stationInput').value.trim();
if (!decimalId) {
alert('请输入站点编号');
return;
}
stations.forEach(station => {
const stationCard = document.createElement('div');
stationCard.className = 'station-card';
stationCard.onclick = () => selectStation(station, stationCard);
stationCard.innerHTML = `
<div class="station-name">${station.station_name || station.station_id}</div>
<div class="station-id">${station.station_id}</div>
<div class="station-id">最后更新: ${station.last_update}</div>
`;
stationList.appendChild(stationCard);
});
} catch (error) {
console.error('加载站点失败:', error);
document.getElementById('stationList').innerHTML = '<div class="error">加载站点失败</div>';
}
}
// 选择站点
function selectStation(station, cardElement) {
// 清除之前的选择
document.querySelectorAll('.station-card').forEach(card => {
card.classList.remove('selected');
});
// 选择当前站点
cardElement.classList.add('selected');
selectedStation = station;
// 启用按钮
document.getElementById('queryBtn').disabled = false;
// 隐藏之前的数据
hideDataContainers();
}
// 隐藏数据容器
function hideDataContainers() {
document.getElementById('chartContainer').classList.remove('show');
document.getElementById('tableContainer').classList.remove('show');
}
// 查询历史数据
async function queryHistoryData() {
if (!selectedStation) {
alert('请先选择一个站点');
// 验证输入是否为有效的十进制数字
if (!/^\d+$/.test(decimalId)) {
alert('请输入有效的十进制编号');
return;
}
@ -397,13 +721,17 @@
try {
const params = new URLSearchParams({
station_id: selectedStation.station_id,
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) {
@ -420,7 +748,7 @@
} catch (error) {
console.error('查询历史数据失败:', error);
alert('查询历史数据失败');
alert('查询历史数据失败: ' + error.message);
}
}

View File

@ -6,6 +6,11 @@ type Station struct {
StationName string `json:"station_name"`
DeviceType string `json:"device_type"`
LastUpdate string `json:"last_update"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Name string `json:"name"`
Location string `json:"location"`
DecimalID string `json:"decimal_id"`
}
// WeatherPoint 气象数据点
@ -26,6 +31,7 @@ type PageData struct {
Title string
ServerTime string
OnlineDevices int
TiandituKey string
}
// SystemStatus 系统状态结构