增加地图展示 在地图上面展示kml边界

This commit is contained in:
zms 2025-11-12 19:06:05 +08:00
parent 65f95662ca
commit 9df02f5b5f
8 changed files with 1160 additions and 49 deletions

View File

@ -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;
```

View File

@ -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)
// 前端SPAAngular静态资源与路由回退
@ -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{

View File

@ -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 系统状态结构

View File

@ -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,13 +57,20 @@ const WeatherMap = {
style: (feature) => this.createStationStyle(feature),
visible: false
});
this.kmlLayerGroup = new ol.layer.Group({
layers: [],
zIndex: 1200,
visible: true
});
},
// 初始化地图
initializeMap(tiandituKey) {
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() {
// 监听缩放事件

File diff suppressed because one or more lines are too long

View File

@ -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>

View File

@ -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
View 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)">&lt; 上一页</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)">下一页 &gt;</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>