增加地图展示 在地图上面展示kml边界
This commit is contained in:
parent
65f95662ca
commit
9df02f5b5f
47
README.md
47
README.md
@ -1,47 +0,0 @@
|
||||
# 气象站数据接收系统
|
||||
|
||||
UDP接收气象站数据,存PostgreSQL。
|
||||
|
||||
## 数据库字段转换
|
||||
|
||||
### 温度
|
||||
- `temp_f`, `dewpoint_f`, `windchill_f`, `indoor_temp_f`: 存储值=实际值×10 (°F)
|
||||
|
||||
### 湿度
|
||||
- `humidity`, `indoor_humidity`: 直接存整数百分比 (%)
|
||||
|
||||
### 风速
|
||||
- `wind_dir`: 直接存角度 (°)
|
||||
- `wind_speed_mph`, `wind_gust_mph`: 存储值=实际值×100 (mph)
|
||||
|
||||
### 降雨量
|
||||
- 所有rain字段: 存储值=实际值×1000 (英寸)
|
||||
|
||||
### 气压
|
||||
- `abs_barometer_in`, `barometer_in`: 存储值=实际值×1000 (英寸汞柱)
|
||||
|
||||
### 其他
|
||||
- `solar_radiation`: 存储值=实际值×100 (W/m²)
|
||||
- `uv`: 直接存整数
|
||||
- `low_battery`: 布尔值
|
||||
|
||||
## 单位转换
|
||||
- 华氏→摄氏: (°F - 32) * 5/9
|
||||
- 英里→公里: mph * 1.60934
|
||||
- 英寸→毫米: in * 25.4
|
||||
- 英寸汞柱→百帕: inHg * 33.8639
|
||||
|
||||
## 查询示例
|
||||
```sql
|
||||
SELECT
|
||||
station_id,
|
||||
timestamp AT TIME ZONE 'Asia/Shanghai' as local_time,
|
||||
temp_f::float/10 as temp_f,
|
||||
(temp_f::float/10 - 32) * 5/9 as temp_c,
|
||||
humidity,
|
||||
wind_speed_mph::float/100 as wind_speed_mph,
|
||||
barometer_in::float/1000 * 33.8639 as barometer_hpa
|
||||
FROM weather_data
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
@ -1,12 +1,16 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"weatherstation/internal/config"
|
||||
"weatherstation/internal/database"
|
||||
@ -15,6 +19,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var staticBaseDir string
|
||||
|
||||
// StartGinServer 启动Gin Web服务器
|
||||
func StartGinServer() error {
|
||||
// 设置Gin模式
|
||||
@ -61,6 +67,7 @@ func StartGinServer() error {
|
||||
break
|
||||
}
|
||||
}
|
||||
staticBaseDir = staticDir
|
||||
r.Static("/static", staticDir)
|
||||
|
||||
// 前端SPA(Angular)静态资源与路由回退
|
||||
@ -159,6 +166,7 @@ func indexHandler(c *gin.Context) {
|
||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||
KmlLayersJSON: buildKmlLayersJSON(),
|
||||
}
|
||||
c.HTML(http.StatusOK, "index.html", data)
|
||||
}
|
||||
@ -170,6 +178,7 @@ func radarNanningHandler(c *gin.Context) {
|
||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||
KmlLayersJSON: buildKmlLayersJSON(),
|
||||
}
|
||||
c.HTML(http.StatusOK, "imdroid_radar.html", data)
|
||||
}
|
||||
@ -181,6 +190,7 @@ func radarGuangzhouHandler(c *gin.Context) {
|
||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||
KmlLayersJSON: buildKmlLayersJSON(),
|
||||
}
|
||||
c.HTML(http.StatusOK, "imdroid_radar.html", data)
|
||||
}
|
||||
@ -192,6 +202,7 @@ func radarHaizhuHandler(c *gin.Context) {
|
||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||
KmlLayersJSON: buildKmlLayersJSON(),
|
||||
}
|
||||
c.HTML(http.StatusOK, "imdroid_radar.html", data)
|
||||
}
|
||||
@ -203,6 +214,7 @@ func radarPanyuHandler(c *gin.Context) {
|
||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||
KmlLayersJSON: buildKmlLayersJSON(),
|
||||
}
|
||||
c.HTML(http.StatusOK, "imdroid_radar.html", data)
|
||||
}
|
||||
@ -213,10 +225,54 @@ func imdroidRadarHandler(c *gin.Context) {
|
||||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||
KmlLayersJSON: buildKmlLayersJSON(),
|
||||
}
|
||||
c.HTML(http.StatusOK, "imdroid_radar.html", data)
|
||||
}
|
||||
|
||||
func buildKmlLayersJSON() template.JS {
|
||||
layers := loadKmlLayers()
|
||||
if len(layers) == 0 {
|
||||
return template.JS("[]")
|
||||
}
|
||||
bytes, err := json.Marshal(layers)
|
||||
if err != nil {
|
||||
log.Printf("序列化KML列表失败: %v", err)
|
||||
return template.JS("[]")
|
||||
}
|
||||
return template.JS(bytes)
|
||||
}
|
||||
|
||||
func loadKmlLayers() []types.KmlLayer {
|
||||
if staticBaseDir == "" {
|
||||
return nil
|
||||
}
|
||||
kmlDir := filepath.Join(staticBaseDir, "kml")
|
||||
entries, err := os.ReadDir(kmlDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
layers := make([]types.KmlLayer, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".kml") {
|
||||
continue
|
||||
}
|
||||
display := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
layers = append(layers, types.KmlLayer{
|
||||
Name: display,
|
||||
URL: "/static/kml/" + name,
|
||||
})
|
||||
}
|
||||
sort.Slice(layers, func(i, j int) bool {
|
||||
return strings.ToLower(layers[i].Name) < strings.ToLower(layers[j].Name)
|
||||
})
|
||||
return layers
|
||||
}
|
||||
|
||||
// systemStatusHandler 处理系统状态API请求
|
||||
func systemStatusHandler(c *gin.Context) {
|
||||
status := types.SystemStatus{
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package types
|
||||
|
||||
import "html/template"
|
||||
|
||||
// Station 站点信息
|
||||
type Station struct {
|
||||
StationID string `json:"station_id"`
|
||||
@ -31,12 +33,19 @@ type WeatherPoint struct {
|
||||
RainTotal float64 `json:"rain_total"`
|
||||
}
|
||||
|
||||
// KmlLayer 描述一个可供前端加载的KML图层
|
||||
type KmlLayer struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// PageData 页面数据结构
|
||||
type PageData struct {
|
||||
Title string
|
||||
ServerTime string
|
||||
OnlineDevices int
|
||||
TiandituKey string
|
||||
KmlLayersJSON template.JS
|
||||
}
|
||||
|
||||
// SystemStatus 系统状态结构
|
||||
|
||||
@ -9,6 +9,12 @@ const WeatherMap = {
|
||||
combinedChart: null,
|
||||
CLUSTER_THRESHOLD: 10,
|
||||
isMapCollapsed: false,
|
||||
kmlLayers: [],
|
||||
kmlLayerGroup: null,
|
||||
activeKmlLayer: null,
|
||||
kmlLastExtent: null,
|
||||
kmlFitButton: null,
|
||||
_kmlControlsBound: false,
|
||||
|
||||
// 初始化地图
|
||||
init(tiandituKey) {
|
||||
@ -16,6 +22,7 @@ const WeatherMap = {
|
||||
this.initializeMap(tiandituKey);
|
||||
this.setupEventListeners();
|
||||
this.setupTileControls();
|
||||
this.setupKmlControls();
|
||||
// 默认叠加:组合反射率(radar),并准备默认索引
|
||||
this.tileZ = 7; this.tileY = 40; this.tileX = 102;
|
||||
this.tileProduct = 'radar';
|
||||
@ -50,6 +57,12 @@ const WeatherMap = {
|
||||
style: (feature) => this.createStationStyle(feature),
|
||||
visible: false
|
||||
});
|
||||
|
||||
this.kmlLayerGroup = new ol.layer.Group({
|
||||
layers: [],
|
||||
zIndex: 1200,
|
||||
visible: true
|
||||
});
|
||||
},
|
||||
|
||||
// 初始化地图
|
||||
@ -57,6 +70,7 @@ const WeatherMap = {
|
||||
const layers = this.createMapLayers(tiandituKey);
|
||||
this.layers = layers;
|
||||
|
||||
const tileOverlayGroup = this.createTileOverlayLayer();
|
||||
this.map = new ol.Map({
|
||||
target: 'map',
|
||||
layers: [
|
||||
@ -65,7 +79,8 @@ const WeatherMap = {
|
||||
layers.terrain,
|
||||
layers.hybrid,
|
||||
// 栅格瓦片叠加层(动态)
|
||||
this.createTileOverlayLayer(),
|
||||
tileOverlayGroup,
|
||||
this.kmlLayerGroup,
|
||||
this.clusterLayer,
|
||||
this.singleStationLayer
|
||||
],
|
||||
@ -219,6 +234,52 @@ const WeatherMap = {
|
||||
});
|
||||
},
|
||||
|
||||
setupKmlControls(){
|
||||
if (this._kmlControlsBound) return;
|
||||
this._kmlControlsBound = true;
|
||||
|
||||
const select = document.getElementById('kmlLayerSelect');
|
||||
const fitBtn = document.getElementById('btnKmlFit');
|
||||
this.kmlFitButton = fitBtn || null;
|
||||
this.kmlLayers = Array.isArray(window.KML_LAYERS) ? window.KML_LAYERS : [];
|
||||
|
||||
if (select) {
|
||||
select.innerHTML = '';
|
||||
const defaultOption = document.createElement('option');
|
||||
defaultOption.value = '';
|
||||
defaultOption.textContent = '不显示';
|
||||
select.appendChild(defaultOption);
|
||||
|
||||
if (this.kmlLayers.length === 0) {
|
||||
const emptyOption = document.createElement('option');
|
||||
emptyOption.value = '';
|
||||
emptyOption.textContent = '暂无KML图层';
|
||||
select.appendChild(emptyOption);
|
||||
select.disabled = true;
|
||||
} else {
|
||||
this.kmlLayers.forEach(layer => {
|
||||
if (!layer || !layer.url) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = layer.url;
|
||||
option.textContent = layer.name || layer.url;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.disabled = false;
|
||||
}
|
||||
|
||||
select.addEventListener('change', (event)=>{
|
||||
const url = event.target.value || '';
|
||||
this.switchKmlLayer(url);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.kmlFitButton) {
|
||||
this.kmlFitButton.addEventListener('click', ()=>this.fitToKmlExtent());
|
||||
}
|
||||
|
||||
this.updateKmlFitButton();
|
||||
},
|
||||
|
||||
async reloadTileTimesAndShow(){
|
||||
try{
|
||||
const times = await this.fetchTileTimes(this.tileProduct, this.tileZ, this.tileY, this.tileX);
|
||||
@ -318,6 +379,15 @@ const WeatherMap = {
|
||||
this.tileLastList = [];
|
||||
},
|
||||
|
||||
clearKmlLayer(){
|
||||
if (!this.kmlLayerGroup) return;
|
||||
const layers = this.kmlLayerGroup.getLayers();
|
||||
if (layers) layers.clear();
|
||||
this.activeKmlLayer = null;
|
||||
this.kmlLastExtent = null;
|
||||
this.updateKmlFitButton();
|
||||
},
|
||||
|
||||
addImageOverlayFromCanvas(canvas, extent4326){
|
||||
const proj = this.map.getView().getProjection();
|
||||
const extentProj = ol.proj.transformExtent(extent4326, 'EPSG:4326', proj);
|
||||
@ -479,6 +549,126 @@ const WeatherMap = {
|
||||
this._hoverBound = true;
|
||||
},
|
||||
|
||||
switchKmlLayer(url){
|
||||
this.clearKmlLayer();
|
||||
if (!url || !this.map || !this.kmlLayerGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const vectorSource = new ol.source.Vector({
|
||||
url: url,
|
||||
format: new ol.format.KML({
|
||||
extractStyles: true,
|
||||
showPointNames: true
|
||||
}),
|
||||
crossOrigin: 'anonymous'
|
||||
});
|
||||
|
||||
const vectorLayer = new ol.layer.Vector({
|
||||
source: vectorSource,
|
||||
style: null,
|
||||
visible: true,
|
||||
opacity: 0.95,
|
||||
zIndex: 1300
|
||||
});
|
||||
|
||||
this.kmlLayerGroup.getLayers().push(vectorLayer);
|
||||
this.activeKmlLayer = vectorLayer;
|
||||
|
||||
const handleSourceReady = ()=>{
|
||||
if (vectorSource.getState && vectorSource.getState() !== 'ready') {
|
||||
return;
|
||||
}
|
||||
vectorSource.un('change', handleSourceReady);
|
||||
const extent = vectorSource.getExtent ? vectorSource.getExtent() : null;
|
||||
if (extent && !ol.extent.isEmpty(extent)) {
|
||||
this.kmlLastExtent = extent.slice ? extent.slice() : extent;
|
||||
} else {
|
||||
this.kmlLastExtent = null;
|
||||
}
|
||||
this.updateKmlFitButton();
|
||||
if (this.kmlLastExtent) {
|
||||
this.fitToKmlExtent({ duration: 600, padding: [80, 80, 80, 80] });
|
||||
}
|
||||
};
|
||||
|
||||
vectorSource.on('change', handleSourceReady);
|
||||
vectorSource.on('error', ()=>{
|
||||
console.error('KML图层加载失败:', url);
|
||||
this.clearKmlLayer();
|
||||
});
|
||||
|
||||
const computeExtentAndEnable = ()=>{
|
||||
try{
|
||||
const features = vectorSource.getFeatures ? vectorSource.getFeatures() : [];
|
||||
if (!features || features.length === 0) {
|
||||
return false;
|
||||
}
|
||||
let extent = ol.extent.createEmpty();
|
||||
features.forEach((feature)=>{
|
||||
const geom = feature && feature.getGeometry ? feature.getGeometry() : null;
|
||||
if (geom) {
|
||||
const geomExtent = geom.getExtent();
|
||||
if (geomExtent) {
|
||||
extent = ol.extent.extend(extent, geomExtent);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!extent || ol.extent.isEmpty(extent)) {
|
||||
return false;
|
||||
}
|
||||
this.kmlLastExtent = extent.slice ? extent.slice() : extent;
|
||||
this.updateKmlFitButton();
|
||||
this.fitToKmlExtent({ duration: 600, padding: [80, 80, 80, 80] });
|
||||
return true;
|
||||
}catch(err){
|
||||
console.warn('计算KML范围失败:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const tryEnableWithDelay = (attempt = 0)=>{
|
||||
if (computeExtentAndEnable()) return;
|
||||
if (attempt > 10) return;
|
||||
setTimeout(()=>tryEnableWithDelay(attempt+1), 120);
|
||||
};
|
||||
|
||||
vectorSource.once('change', ()=>{
|
||||
tryEnableWithDelay();
|
||||
});
|
||||
vectorSource.once('addfeature', ()=>{
|
||||
tryEnableWithDelay();
|
||||
});
|
||||
vectorSource.once('featuresloadend', ()=>{
|
||||
tryEnableWithDelay();
|
||||
});
|
||||
|
||||
if (vectorSource.getState && vectorSource.getState() === 'ready') {
|
||||
tryEnableWithDelay();
|
||||
handleSourceReady();
|
||||
}
|
||||
},
|
||||
|
||||
updateKmlFitButton(){
|
||||
if (!this.kmlFitButton) return;
|
||||
this.kmlFitButton.disabled = !this.kmlLastExtent;
|
||||
},
|
||||
|
||||
fitToKmlExtent(options = {}){
|
||||
if (!this.map || !this.kmlLastExtent) return;
|
||||
const view = this.map.getView();
|
||||
if (!view) return;
|
||||
const padding = Array.isArray(options.padding) ? options.padding : [60, 60, 60, 60];
|
||||
const duration = typeof options.duration === 'number' ? options.duration : 500;
|
||||
const maxZoom = view.getMaxZoom();
|
||||
const targetMaxZoom = typeof maxZoom === 'number' ? Math.min(maxZoom, 17) : 17;
|
||||
view.fit(this.kmlLastExtent, {
|
||||
padding: padding,
|
||||
duration: duration,
|
||||
maxZoom: targetMaxZoom
|
||||
});
|
||||
},
|
||||
|
||||
// 设置事件监听
|
||||
setupEventListeners() {
|
||||
// 监听缩放事件
|
||||
|
||||
256
static/kml/selected_polygons.kml
Normal file
256
static/kml/selected_polygons.kml
Normal file
File diff suppressed because one or more lines are too long
@ -541,6 +541,13 @@
|
||||
<option value="radar" selected>水汽含量</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="text-sm text-gray-600">区域图层:</label>
|
||||
<select id="kmlLayerSelect" class="px-2 py-1 border border-gray-300 rounded text-sm min-w-[200px]">
|
||||
<option value="">不显示</option>
|
||||
</select>
|
||||
<button id="btnKmlFit" class="px-2 py-1 text-sm border border-gray-400 rounded bg-white text-gray-800 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed" disabled>定位</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button id="btnTilePrev" class="px-2 py-1 text-sm border border-gray-400 rounded bg-white text-gray-800 hover:bg-gray-100">上一时次</button>
|
||||
<span id="tileCountInfo" class="text-xs text-gray-800">共0条,第0条</span>
|
||||
@ -602,6 +609,7 @@
|
||||
|
||||
<script>
|
||||
window.TIANDITU_KEY = '{{.TiandituKey}}';
|
||||
window.KML_LAYERS = {{.KmlLayersJSON}};
|
||||
</script>
|
||||
<script defer src="/static/js/alpinejs.min.js"></script>
|
||||
<script src="/static/js/utils.js"></script>
|
||||
|
||||
@ -264,6 +264,17 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===== 前端直接配置KML图层 =====
|
||||
window.TIANDITU_KEY = '0c260b8a094a4e0bc507808812cefdac'; // 天地图Key
|
||||
window.KML_LAYERS = [
|
||||
{
|
||||
name: "昭君镇示范社区",
|
||||
url: "../static/kml/selected_polygons.kml"
|
||||
}
|
||||
// 如果有更多KML文件,继续添加:
|
||||
// { name: "另一个区域", url: "../static/kml/another.kml" }
|
||||
];
|
||||
|
||||
let selectedStation = null;
|
||||
let combinedChart = null;
|
||||
|
||||
|
||||
628
web/index_local.html
Normal file
628
web/index_local.html
Normal file
@ -0,0 +1,628 @@
|
||||
<!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>
|
||||
<link rel="stylesheet" href="/static/css/ol.css">
|
||||
<script src="/static/js/ol.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.px-7p {
|
||||
padding-left: 7%;
|
||||
padding-right: 7%;
|
||||
}
|
||||
|
||||
.content-narrow {
|
||||
width: 86%;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-narrow {
|
||||
width: 92%;
|
||||
}
|
||||
}
|
||||
|
||||
.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-wrap: wrap;
|
||||
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: 38vh;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.map-control-btn {
|
||||
padding: 3px 8px;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.map-control-btn:hover {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.map-control-btn.active {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.map-toggle-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1001;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
background-color: #007bff;
|
||||
}
|
||||
|
||||
.station-info-title {
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
|
||||
.chart-container {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
background-color: #fff;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chart-container.show {
|
||||
display: block;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
.accuracy-panel {
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
color: #374151; /* 灰色文字 */
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.accuracy-panel .item { margin-left: 8px; }
|
||||
.accuracy-panel .label { color: #6b7280; margin-right: 4px; }
|
||||
.accuracy-panel .value { font-weight: 600; color: #111827; }
|
||||
|
||||
.chart-wrapper {
|
||||
height: 500px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
margin-top: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-container.show {
|
||||
display: block;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
background-color: #e9ecef;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.device-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.device-modal-content {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
height: auto;
|
||||
max-height: 70vh;
|
||||
width: 90%;
|
||||
max-width: 720px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.15);
|
||||
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 x-data="{ showPastForecast: false, deviceModalOpen: false }" class="text-[14px] md:text-[15px]" x-init="window.addEventListener('close-device-modal', () => { deviceModalOpen = false })">
|
||||
{{ template "header" . }}
|
||||
|
||||
<div id="deviceModal" class="device-modal" x-show="deviceModalOpen" x-transition.opacity @click.self="deviceModalOpen=false">
|
||||
<div class="device-modal-content bg-white shadow-xl" x-transition.scale.duration.150ms>
|
||||
<div class="device-list-header flex items-center justify-between border-b">
|
||||
<div class="text-sm">设备列表</div>
|
||||
<span class="close-modal" @click="deviceModalOpen=false">×</span>
|
||||
</div>
|
||||
<div id="deviceList" class="device-list">
|
||||
</div>
|
||||
<div class="device-list-footer">
|
||||
<div class="pagination">
|
||||
<button class="pagination-btn" id="prevPage" :disabled="window.WeatherApp.currentPage <= 1" @click="window.WeatherApp.updateDeviceList(window.WeatherApp.currentPage - 1)">< 上一页</button>
|
||||
<span>第 <span id="currentPage">1</span> 页,共 <span id="totalPages">1</span> 页</span>
|
||||
<button class="pagination-btn" id="nextPage" :disabled="window.WeatherApp.currentPage >= Math.ceil(window.WeatherApp.filteredDevices.length / window.WeatherApp.itemsPerPage)" @click="window.WeatherApp.updateDeviceList(window.WeatherApp.currentPage + 1)">下一页 ></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container content-narrow py-5">
|
||||
<div class="system-info bg-gray-100 p-3 mb-5 rounded text-sm">
|
||||
<strong>在线设备: </strong> <span id="onlineDevices">{{.OnlineDevices}}</span> 个 |
|
||||
<strong>总设备: </strong> <a href="#" id="showDeviceList" class="text-blue-600 hover:text-blue-700 underline-offset-2" @click.prevent="deviceModalOpen = true; window.WeatherApp.updateDeviceList(1)"><span id="wh65lpCount">0</span> 个</a>
|
||||
</div>
|
||||
|
||||
<div class="controls flex flex-col gap-4 mb-5 p-4 border rounded bg-white">
|
||||
<div class="control-row flex items-center gap-4 flex-wrap">
|
||||
<div class="station-input-group flex items-center gap-1">
|
||||
<label for="stationInput" class="text-sm text-gray-600">站点编号:</label>
|
||||
<input type="text" id="stationInput" placeholder="" class="w-32 px-2 py-1 border border-gray-300 rounded text-sm font-mono">
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="mapType" class="text-sm text-gray-600">地图类型:</label>
|
||||
<select id="mapType" onchange="switchLayer(this.value)" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="satellite">卫星图</option>
|
||||
<option value="vector">矢量图</option>
|
||||
<option value="terrain">地形图</option>
|
||||
<option value="hybrid">混合地形图</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="forecastProvider" class="text-sm text-gray-600">预报源:</label>
|
||||
<select id="forecastProvider" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="">不显示预报</option>
|
||||
<option value="imdroid_mix" selected>英卓 V4</option>
|
||||
<option value="open-meteo">英卓 V3</option>
|
||||
<option value="caiyun">英卓 V2</option>
|
||||
<option value="imdroid">英卓 V1</option>
|
||||
|
||||
<!-- <option value="cma">中央气象台</option>-->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="legendMode" class="text-sm text-gray-600">图例展示:</label>
|
||||
<select id="legendMode" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="combo_standard" selected>综合</option>
|
||||
<option value="verify_all">全部气象要素对比</option>
|
||||
<option value="temp_compare">温度对比</option>
|
||||
<option value="hum_compare">湿度对比</option>
|
||||
<option value="rain_all">降水(+1/+2/+3h)</option>
|
||||
<option value="pressure_compare">气压对比</option>
|
||||
<option value="wind_compare">风速对比</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-row flex items-center gap-4 flex-wrap">
|
||||
<div class="control-group flex items-center gap-1">
|
||||
<label for="interval" class="text-sm text-gray-600">数据粒度:</label>
|
||||
<select id="interval" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="raw">原始(16s)</option>
|
||||
<option value="10min">10分钟</option>
|
||||
<option value="30min">30分钟</option>
|
||||
<option value="1hour" selected>1小时</option>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group" id="timeRangeGroup">
|
||||
<label for="startDate" class="text-sm text-gray-600">开始时间:</label>
|
||||
<input type="datetime-local" id="startDate" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<label for="endDate" class="text-sm text-gray-600">结束时间:</label>
|
||||
<input type="datetime-local" id="endDate" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<button onclick="queryHistoryData()" id="queryBtn" class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-3 py-1 rounded">查看历史数据</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-row flex items-center gap-3 flex-wrap">
|
||||
<div class="control-group">
|
||||
<label class="text-sm text-gray-600">叠加显示:</label>
|
||||
<select id="tileProduct" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="none">不显示</option>
|
||||
<option value="rain">1h 实际降雨</option>
|
||||
<option value="radar" selected>水汽含量</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="text-sm text-gray-600">区域图层:</label>
|
||||
<select id="kmlLayerSelect" class="px-2 py-1 border border-gray-300 rounded text-sm min-w-[200px]">
|
||||
<option value="">不显示</option>
|
||||
</select>
|
||||
<button id="btnKmlFit" class="px-2 py-1 text-sm border border-gray-400 rounded bg-white text-gray-800 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed" disabled>定位</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button id="btnTilePrev" class="px-2 py-1 text-sm border border-gray-400 rounded bg-white text-gray-800 hover:bg-gray-100">上一时次</button>
|
||||
<span id="tileCountInfo" class="text-xs text-gray-800">共0条,第0条</span>
|
||||
<button id="btnTileNext" class="px-2 py-1 text-sm border border-gray-400 rounded bg-white text-gray-800 hover:bg-gray-100">下一时次</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="text-sm text-gray-600">时间:</label>
|
||||
<select id="tileTimeSelect" class="px-2 py-1 border border-gray-300 rounded text-sm min-w-[220px]">
|
||||
<option value="">请选择时间</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-container border border-gray-200 rounded" id="mapContainer">
|
||||
<div id="map"></div>
|
||||
<button class="map-toggle-btn bg-blue-600 hover:bg-blue-700 text-white" id="toggleMapBtn" onclick="toggleMap()">折叠地图</button>
|
||||
<div id="tileValueTooltip" style="position:absolute;pointer-events:none;z-index:1003;display:none;background:rgba(0,0,0,0.65);color:#fff;font-size:12px;padding:4px 6px;border-radius:4px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container" id="chartContainer">
|
||||
<div id="stationInfoTitle" class="station-info-title"></div>
|
||||
<div id="accuracyPanel" class="accuracy-panel">
|
||||
<span class="item"><span class="label">+1h</span><span id="accH1" class="value">--</span></span>
|
||||
<span class="item"><span class="label">+2h</span><span id="accH2" class="value">--</span></span>
|
||||
<span class="item"><span class="label">+3h</span><span id="accH3" class="value">--</span></span>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="combinedChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container" id="tableContainer">
|
||||
<div id="forecastToggleContainer" style="padding: 8px 12px;font-size: 12px;color: #666;display: none;display: flex;justify-content: flex-start;align-items: center;align-content: center;">
|
||||
<label style="display: flex;align-items: center;gap: 5px;">
|
||||
<input type="checkbox" id="showPastForecast" style="vertical-align: middle;" x-model="showPastForecast" @change="window.WeatherTable.display(window.WeatherApp.cachedHistoryData, window.WeatherApp.cachedForecastData)">
|
||||
显示历史预报
|
||||
</label>
|
||||
</div>
|
||||
<table class="min-w-full text-sm text-center">
|
||||
<thead>
|
||||
<tr id="tableHeader">
|
||||
<th>时间</th>
|
||||
<th>温度 (°C)</th>
|
||||
<th>湿度 (%)</th>
|
||||
<th>气压 (hPa)</th>
|
||||
<th>风速 (m/s)</th>
|
||||
<th>风向 (°)</th>
|
||||
<th>雨量 (mm)</th>
|
||||
<th>光照 (lux)</th>
|
||||
<th>紫外线</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.TIANDITU_KEY = '0c260b8a094a4e0bc507808812cefdac';
|
||||
window.KML_LAYERS = [
|
||||
{
|
||||
name: "昭君镇示范社区(典型防控区+社区边界)",
|
||||
url: "/static/kml/selected_polygons.kml"
|
||||
}
|
||||
// 添加更多KML文件:
|
||||
// { name: "其他区域", url: "./static/kml/other.kml" }
|
||||
];
|
||||
</script>
|
||||
<script defer src="/static/js/alpinejs.min.js"></script>
|
||||
<script src="/static/js/utils.js"></script>
|
||||
<script src="/static/js/weather-app.js"></script>
|
||||
<script src="/static/js/weather-chart.js"></script>
|
||||
<script src="/static/js/weather-table.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user