Compare commits
2 Commits
f2deb5512f
...
ecf3a153f0
| Author | SHA1 | Date | |
|---|---|---|---|
| ecf3a153f0 | |||
| 1c88bde080 |
203
gin_server.go
203
gin_server.go
@ -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(×tamp, &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() {
|
||||
|
||||
36
model/db.go
36
model/db.go
@ -42,21 +42,33 @@ 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() {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
6
types.go
6
types.go
@ -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 系统状态结构
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user