feat: 新增通用的雷达站
This commit is contained in:
parent
ef1d2f57e1
commit
9604c62f4c
@ -26,17 +26,21 @@ func GetOnlineDevicesCount(db *sql.DB) int {
|
|||||||
func GetStations(db *sql.DB) ([]types.Station, error) {
|
func GetStations(db *sql.DB) ([]types.Station, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT DISTINCT s.station_id,
|
SELECT DISTINCT s.station_id,
|
||||||
|
COALESCE(s.station_alias, '') as station_alias,
|
||||||
COALESCE(s.password, '') as station_name,
|
COALESCE(s.password, '') as station_name,
|
||||||
'WH65LP' as device_type,
|
'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.latitude, 0) as latitude,
|
||||||
COALESCE(s.longitude, 0) as longitude,
|
COALESCE(s.longitude, 0) as longitude,
|
||||||
COALESCE(s.name, '') as name,
|
COALESCE(s.name, '') as name,
|
||||||
COALESCE(s.location, '') as location
|
COALESCE(s.location, '') as location,
|
||||||
|
COALESCE(s.z, 0) as z,
|
||||||
|
COALESCE(s.y, 0) as y,
|
||||||
|
COALESCE(s.x, 0) as x
|
||||||
FROM stations s
|
FROM stations s
|
||||||
LEFT JOIN rs485_weather_data r ON s.station_id = r.station_id
|
LEFT JOIN rs485_weather_data r ON s.station_id = r.station_id
|
||||||
WHERE s.station_id LIKE 'RS485-%'
|
WHERE s.station_id LIKE 'RS485-%'
|
||||||
GROUP BY s.station_id, s.password, s.latitude, s.longitude, s.name, s.location
|
GROUP BY s.station_id, s.station_alias, s.password, s.latitude, s.longitude, s.name, s.location, s.z, s.y, s.x
|
||||||
ORDER BY s.station_id`
|
ORDER BY s.station_id`
|
||||||
|
|
||||||
rows, err := db.Query(query)
|
rows, err := db.Query(query)
|
||||||
@ -51,6 +55,7 @@ func GetStations(db *sql.DB) ([]types.Station, error) {
|
|||||||
var lastUpdate time.Time
|
var lastUpdate time.Time
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&station.StationID,
|
&station.StationID,
|
||||||
|
&station.StationAlias,
|
||||||
&station.StationName,
|
&station.StationName,
|
||||||
&station.DeviceType,
|
&station.DeviceType,
|
||||||
&lastUpdate,
|
&lastUpdate,
|
||||||
@ -58,6 +63,9 @@ func GetStations(db *sql.DB) ([]types.Station, error) {
|
|||||||
&station.Longitude,
|
&station.Longitude,
|
||||||
&station.Name,
|
&station.Name,
|
||||||
&station.Location,
|
&station.Location,
|
||||||
|
&station.Z,
|
||||||
|
&station.Y,
|
||||||
|
&station.X,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
38
internal/database/stations_coords.go
Normal file
38
internal/database/stations_coords.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StationCoord holds a station_id with geographic coordinates.
|
||||||
|
type StationCoord struct {
|
||||||
|
StationID string
|
||||||
|
Lat float64
|
||||||
|
Lon float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWH65LPStationsWithLatLon returns WH65LP stations that have non-null and non-zero lat/lon.
|
||||||
|
func ListWH65LPStationsWithLatLon(ctx context.Context, db *sql.DB) ([]StationCoord, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT station_id, latitude, longitude
|
||||||
|
FROM stations
|
||||||
|
WHERE device_type = 'WH65LP'
|
||||||
|
AND latitude IS NOT NULL AND longitude IS NOT NULL
|
||||||
|
AND latitude <> 0 AND longitude <> 0
|
||||||
|
ORDER BY station_id`
|
||||||
|
rows, err := db.QueryContext(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []StationCoord
|
||||||
|
for rows.Next() {
|
||||||
|
var s StationCoord
|
||||||
|
if err := rows.Scan(&s.StationID, &s.Lat, &s.Lon); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@ -349,6 +349,30 @@ func runOnceFromNMC(ctx context.Context, opts Options) error {
|
|||||||
if err := fetchAndStoreRadarRealtimeFor(ctx, "番禺雷达站", 23.022500, 113.331300); err != nil {
|
if err := fetchAndStoreRadarRealtimeFor(ctx, "番禺雷达站", 23.022500, 113.331300); err != nil {
|
||||||
log.Printf("[radar] realtime(PANYU) failed: %v", err)
|
log.Printf("[radar] realtime(PANYU) failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 并对 stations 表中符合条件(WH65LP 且有非零经纬度)的站点,抓取彩云实况并写入 radar_weather,alias=station_id
|
||||||
|
// 这样 radar_weather 可同时承载“雷达站别名”和“具体设备 station_id”两类记录。
|
||||||
|
func() {
|
||||||
|
// 预先检查 token,避免对每个站点重复报错
|
||||||
|
token := os.Getenv("CAIYUN_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
token = config.GetConfig().Forecast.CaiyunToken
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
log.Printf("[radar] skip station realtime: missing CAIYUN_TOKEN")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
coords, err := database.ListWH65LPStationsWithLatLon(ctx, database.GetDB())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[radar] list WH65LP stations failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range coords {
|
||||||
|
if err := fetchAndStoreRadarRealtimeFor(ctx, s.StationID, s.Lat, s.Lon); err != nil {
|
||||||
|
log.Printf("[radar] realtime(station=%s) failed: %v", s.StationID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,7 @@ func StartGinServer() error {
|
|||||||
r.GET("/radar/guangzhou", radarGuangzhouHandler)
|
r.GET("/radar/guangzhou", radarGuangzhouHandler)
|
||||||
r.GET("/radar/panyu", radarPanyuHandler)
|
r.GET("/radar/panyu", radarPanyuHandler)
|
||||||
r.GET("/radar/haizhu", radarHaizhuHandler)
|
r.GET("/radar/haizhu", radarHaizhuHandler)
|
||||||
|
r.GET("/radar/imdroid", imdroidRadarHandler)
|
||||||
|
|
||||||
// API路由组
|
// API路由组
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
@ -114,6 +115,16 @@ func radarPanyuHandler(c *gin.Context) {
|
|||||||
c.HTML(http.StatusOK, "radar_panyu.html", data)
|
c.HTML(http.StatusOK, "radar_panyu.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func imdroidRadarHandler(c *gin.Context) {
|
||||||
|
data := types.PageData{
|
||||||
|
Title: "英卓雷达站",
|
||||||
|
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||||
|
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||||
|
}
|
||||||
|
c.HTML(http.StatusOK, "imdroid_radar.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
// systemStatusHandler 处理系统状态API请求
|
// systemStatusHandler 处理系统状态API请求
|
||||||
func systemStatusHandler(c *gin.Context) {
|
func systemStatusHandler(c *gin.Context) {
|
||||||
status := types.SystemStatus{
|
status := types.SystemStatus{
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package types
|
|||||||
// Station 站点信息
|
// Station 站点信息
|
||||||
type Station struct {
|
type Station struct {
|
||||||
StationID string `json:"station_id"`
|
StationID string `json:"station_id"`
|
||||||
|
StationAlias string `json:"station_alias"`
|
||||||
StationName string `json:"station_name"`
|
StationName string `json:"station_name"`
|
||||||
DeviceType string `json:"device_type"`
|
DeviceType string `json:"device_type"`
|
||||||
LastUpdate string `json:"last_update"`
|
LastUpdate string `json:"last_update"`
|
||||||
@ -10,6 +11,9 @@ type Station struct {
|
|||||||
Longitude float64 `json:"longitude"`
|
Longitude float64 `json:"longitude"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
|
Z int `json:"z"`
|
||||||
|
Y int `json:"y"`
|
||||||
|
X int `json:"x"`
|
||||||
DecimalID string `json:"decimal_id"`
|
DecimalID string `json:"decimal_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
<h1 class="text-xl md:text-2xl font-semibold">{{ .Title }}</h1>
|
<h1 class="text-xl md:text-2xl font-semibold">{{ .Title }}</h1>
|
||||||
<nav class="text-sm flex items-center gap-3">
|
<nav class="text-sm flex items-center gap-3">
|
||||||
<a href="/" class="text-blue-600 hover:text-blue-700">首页</a>
|
<a href="/" class="text-blue-600 hover:text-blue-700">首页</a>
|
||||||
|
<a href="/radar/imdroid" class="text-blue-600 hover:text-blue-700">英卓雷达站</a>
|
||||||
<a href="/radar/nanning" class="text-blue-600 hover:text-blue-700">南宁雷达站</a>
|
<a href="/radar/nanning" class="text-blue-600 hover:text-blue-700">南宁雷达站</a>
|
||||||
<a href="/radar/guangzhou" class="text-blue-600 hover:text-blue-700">广州雷达站</a>
|
<a href="/radar/guangzhou" class="text-blue-600 hover:text-blue-700">广州雷达站</a>
|
||||||
<a href="/radar/panyu" class="text-blue-600 hover:text-blue-700">番禺雷达站</a>
|
<a href="/radar/panyu" class="text-blue-600 hover:text-blue-700">番禺雷达站</a>
|
||||||
|
|||||||
561
templates/imdroid_radar.html
Normal file
561
templates/imdroid_radar.html
Normal file
@ -0,0 +1,561 @@
|
|||||||
|
<!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>
|
||||||
|
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 0; }
|
||||||
|
.content-narrow { width: 86%; max-width: 1200px; margin: 0 auto; }
|
||||||
|
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 20px; }
|
||||||
|
.plot-box { width: clamp(320px, 72vw, 680px); margin: 0 auto; aspect-ratio: 1 / 1; overflow: hidden; }
|
||||||
|
.plot-box-sm { width: clamp(320px, 65vw, 612px); margin: 0 auto; aspect-ratio: 1 / 1; overflow: hidden; }
|
||||||
|
@supports not (aspect-ratio: 1 / 1) { .plot-box { height: 520px; } }
|
||||||
|
</style>
|
||||||
|
<script src="/static/js/plotly-2.27.0.min.js"></script>
|
||||||
|
<script>
|
||||||
|
let gZ=0, gY=0, gX=0;
|
||||||
|
let gTimes = [];
|
||||||
|
let gCurrentIdx = -1;
|
||||||
|
let gAlias = '';
|
||||||
|
// 3H 预报相关全局量
|
||||||
|
let gTileValues = null, gXs = null, gYs = null;
|
||||||
|
let gWindFromDeg = null, gWindSpeedMS = null;
|
||||||
|
let gTileDT = null;
|
||||||
|
let gStLat = null, gStLon = null;
|
||||||
|
|
||||||
|
async function loadStations() {
|
||||||
|
const sel = document.getElementById('stationSelect');
|
||||||
|
sel.innerHTML = '';
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = ''; opt.textContent = '请选择站点…';
|
||||||
|
sel.appendChild(opt);
|
||||||
|
const res = await fetch('/api/stations');
|
||||||
|
const stations = await res.json();
|
||||||
|
stations
|
||||||
|
.filter(s => s.device_type === 'WH65LP' && s.latitude && s.longitude)
|
||||||
|
.forEach(s => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = s.station_id; // 用 station_id 作为联动主键
|
||||||
|
const alias = (s.station_alias && String(s.station_alias).trim().length > 0) ? s.station_alias : s.station_id;
|
||||||
|
o.textContent = alias; // 仅显示别名
|
||||||
|
o.dataset.z = s.z; o.dataset.y = s.y; o.dataset.x = s.x;
|
||||||
|
o.dataset.lat = s.latitude; o.dataset.lon = s.longitude;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRealtimeBox(j){
|
||||||
|
const set = (id, v) => document.getElementById(id).textContent = (v ?? '') === '' ? '' : String(v);
|
||||||
|
set('rt_alias', j.alias);
|
||||||
|
set('rt_dt', j.dt);
|
||||||
|
set('rt_lat', j.lat);
|
||||||
|
set('rt_lon', j.lon);
|
||||||
|
if (typeof j.temperature === 'number') set('rt_t', j.temperature.toFixed(2)); else set('rt_t','');
|
||||||
|
if (typeof j.humidity === 'number') set('rt_h', j.humidity.toFixed(0)); else set('rt_h','');
|
||||||
|
if (typeof j.wind_speed === 'number') set('rt_ws', j.wind_speed.toFixed(2)); else set('rt_ws','');
|
||||||
|
if (typeof j.wind_direction === 'number') set('rt_wd', j.wind_direction.toFixed(0)); else set('rt_wd','');
|
||||||
|
if (typeof j.cloudrate === 'number') set('rt_c', j.cloudrate.toFixed(2)); else set('rt_c','');
|
||||||
|
if (typeof j.visibility === 'number') set('rt_vis', j.visibility.toFixed(2)); else set('rt_vis','');
|
||||||
|
if (typeof j.dswrf === 'number') set('rt_dswrf', j.dswrf.toFixed(1)); else set('rt_dswrf','');
|
||||||
|
if (typeof j.pressure === 'number') set('rt_p', j.pressure.toFixed(0)); else set('rt_p','');
|
||||||
|
if (typeof j.wind_direction === 'number') gWindFromDeg = Number(j.wind_direction);
|
||||||
|
if (typeof j.wind_speed === 'number') gWindSpeedMS = Number(j.wind_speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRealtime(alias) {
|
||||||
|
const res = await fetch(`/api/radar/weather_latest?alias=${encodeURIComponent(alias)}`);
|
||||||
|
if (!res.ok) throw new Error('实时数据不存在');
|
||||||
|
const j = await res.json();
|
||||||
|
setRealtimeBox(j);
|
||||||
|
const miss = document.getElementById('rt_missing');
|
||||||
|
if (miss) miss.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRealtimeAt(alias, dtStr) {
|
||||||
|
const res = await fetch(`/api/radar/weather_at?alias=${encodeURIComponent(alias)}&dt=${encodeURIComponent(dtStr)}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const miss = document.getElementById('rt_missing');
|
||||||
|
if (miss) miss.classList.remove('hidden');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const j = await res.json();
|
||||||
|
setRealtimeBox(j);
|
||||||
|
const miss = document.getElementById('rt_missing');
|
||||||
|
if (miss) miss.classList.add('hidden');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLatestTile(z, y, x) {
|
||||||
|
const status = document.getElementById('tile_status');
|
||||||
|
const res = await fetch(`/api/radar/latest?z=${z}&y=${y}&x=${x}`);
|
||||||
|
if (!res.ok) { status.textContent = '未找到瓦片'; return; }
|
||||||
|
const t = await res.json();
|
||||||
|
const fmt = (n, d=5)=> Number(n).toFixed(d);
|
||||||
|
document.getElementById('tile_dt').textContent = t.dt;
|
||||||
|
document.getElementById('tile_z').textContent = t.z;
|
||||||
|
document.getElementById('tile_y').textContent = t.y;
|
||||||
|
document.getElementById('tile_x').textContent = t.x;
|
||||||
|
document.getElementById('tile_w').textContent = fmt(t.west);
|
||||||
|
document.getElementById('tile_s').textContent = fmt(t.south);
|
||||||
|
document.getElementById('tile_e').textContent = fmt(t.east);
|
||||||
|
document.getElementById('tile_n').textContent = fmt(t.north);
|
||||||
|
document.getElementById('tile_res').textContent = fmt(t.res_deg, 6);
|
||||||
|
status.textContent = '';
|
||||||
|
renderTilePlot(t);
|
||||||
|
const idx = gTimes.indexOf(t.dt);
|
||||||
|
if (idx >= 0) {
|
||||||
|
gCurrentIdx = idx;
|
||||||
|
updateCountAndButtons();
|
||||||
|
updateSlider();
|
||||||
|
const sel = document.getElementById('timeSelect');
|
||||||
|
sel.value = t.dt;
|
||||||
|
}
|
||||||
|
// 同步气象:按瓦片时间向下取整到10分钟,查询该桶的实况
|
||||||
|
if (gAlias) {
|
||||||
|
const bucket = dtToBucket10(t.dt);
|
||||||
|
if (bucket) {
|
||||||
|
await loadRealtimeAt(gAlias, bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maybeCalcSector();
|
||||||
|
maybePlotSquare();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTileAt(z, y, x, dtStr) {
|
||||||
|
const status = document.getElementById('tile_status');
|
||||||
|
const res = await fetch(`/api/radar/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}`);
|
||||||
|
if (!res.ok) { status.textContent = '未找到瓦片'; return; }
|
||||||
|
const t = await res.json();
|
||||||
|
const fmt = (n, d=5)=> Number(n).toFixed(d);
|
||||||
|
document.getElementById('tile_dt').textContent = t.dt;
|
||||||
|
document.getElementById('tile_z').textContent = t.z;
|
||||||
|
document.getElementById('tile_y').textContent = t.y;
|
||||||
|
document.getElementById('tile_x').textContent = t.x;
|
||||||
|
document.getElementById('tile_w').textContent = fmt(t.west);
|
||||||
|
document.getElementById('tile_s').textContent = fmt(t.south);
|
||||||
|
document.getElementById('tile_e').textContent = fmt(t.east);
|
||||||
|
document.getElementById('tile_n').textContent = fmt(t.north);
|
||||||
|
document.getElementById('tile_res').textContent = fmt(t.res_deg, 6);
|
||||||
|
status.textContent = '';
|
||||||
|
renderTilePlot(t);
|
||||||
|
if (gAlias) {
|
||||||
|
const bucket = dtToBucket10(t.dt);
|
||||||
|
if (bucket) { await loadRealtimeAt(gAlias, bucket); }
|
||||||
|
}
|
||||||
|
maybeCalcSector();
|
||||||
|
maybePlotSquare();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDTLocal(dt){
|
||||||
|
const pad = (n)=> String(n).padStart(2,'0');
|
||||||
|
return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||||||
|
}
|
||||||
|
function fromDTLocalInput(s){
|
||||||
|
if(!s) return null;
|
||||||
|
const t = new Date(s.replace('T','-').replace(/-/g,'/'));
|
||||||
|
const pad=(n)=> String(n).padStart(2,'0');
|
||||||
|
return `${t.getFullYear()}-${pad(t.getMonth()+1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateTimes(z, y, x, fromStr, toStr) {
|
||||||
|
try {
|
||||||
|
let url = `/api/radar/times?z=${z}&y=${y}&x=${x}`;
|
||||||
|
if (fromStr && toStr) {
|
||||||
|
url += `&from=${encodeURIComponent(fromStr)}&to=${encodeURIComponent(toStr)}`;
|
||||||
|
} else {
|
||||||
|
url += '&limit=60';
|
||||||
|
}
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const j = await res.json();
|
||||||
|
const sel = document.getElementById('timeSelect');
|
||||||
|
while (sel.options.length > 1) sel.remove(1);
|
||||||
|
gTimes = j.times || [];
|
||||||
|
gTimes.forEach(dt => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = dt; opt.textContent = dt; sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (gTimes.length > 0) {
|
||||||
|
sel.value = gTimes[0];
|
||||||
|
gCurrentIdx = 0;
|
||||||
|
} else {
|
||||||
|
gCurrentIdx = -1;
|
||||||
|
}
|
||||||
|
updateCountAndButtons();
|
||||||
|
updateSlider();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCountAndButtons(){
|
||||||
|
const info = document.getElementById('countInfo');
|
||||||
|
const total = gTimes.length;
|
||||||
|
const idxDisp = gCurrentIdx >= 0 ? (gCurrentIdx+1) : 0;
|
||||||
|
info.textContent = `共${total}条,第${idxDisp}条`;
|
||||||
|
const prev = document.getElementById('btnPrev');
|
||||||
|
const next = document.getElementById('btnNext');
|
||||||
|
// gTimes 按时间倒序(最新在前),上一时次=更老 => 允许在 idx < total-1
|
||||||
|
if (prev) prev.disabled = !(total > 0 && gCurrentIdx >= 0 && gCurrentIdx < total - 1);
|
||||||
|
// 下一时次=更近 => 允许在 idx > 0
|
||||||
|
if (next) next.disabled = !(total > 0 && gCurrentIdx > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSlider(){
|
||||||
|
const slider = document.getElementById('timeSlider');
|
||||||
|
if (!slider) return;
|
||||||
|
const total = gTimes.length;
|
||||||
|
slider.max = total > 0 ? String(total-1) : '0';
|
||||||
|
// 最新(gCurrentIdx=0)时,滑块在最右端(value=max)
|
||||||
|
slider.value = gCurrentIdx >= 0 ? String((total-1) - gCurrentIdx) : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtToBucket10(dtStr){
|
||||||
|
// dtStr: "YYYY-MM-DD HH:MM:SS"
|
||||||
|
if (!dtStr) return '';
|
||||||
|
const parts = dtStr.split(/[- :]/g);
|
||||||
|
if (parts.length < 6) return '';
|
||||||
|
const y=+parts[0], m=+parts[1]-1, d=+parts[2], hh=+parts[3], mm=+parts[4];
|
||||||
|
const date = new Date(y, m, d, hh, mm, 0, 0);
|
||||||
|
const bucketMin = Math.floor(mm/10)*10;
|
||||||
|
date.setMinutes(bucketMin, 0, 0);
|
||||||
|
const pad=(n)=> String(n).padStart(2,'0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTilePlot(t) {
|
||||||
|
if (!t || !t.values) return;
|
||||||
|
const w = t.width, h = t.height;
|
||||||
|
const resDeg = t.res_deg;
|
||||||
|
const west = t.west, south = t.south;
|
||||||
|
const xs = new Array(w);
|
||||||
|
for (let c = 0; c < w; c++) xs[c] = west + (c + 0.5) * resDeg;
|
||||||
|
const ys = new Array(h);
|
||||||
|
for (let r = 0; r < h; r++) ys[r] = south + (r + 0.5) * resDeg;
|
||||||
|
// 保存到全局供后续计算
|
||||||
|
gTileValues = t.values; gXs = xs; gYs = ys;
|
||||||
|
try { gTileDT = new Date((t.dt || '').replace(/-/g,'/')); } catch {}
|
||||||
|
const colors = ["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
|
||||||
|
// 构建离散色阶(0..14)+ customdata(用于 hover 展示 dBZ)
|
||||||
|
const zBins = []; const custom = [];
|
||||||
|
for (let r = 0; r < h; r++) {
|
||||||
|
const row = t.values[r]; const rowBins = []; const rowCustom = [];
|
||||||
|
for (let c = 0; c < w; c++) {
|
||||||
|
const val = row[c];
|
||||||
|
if (val == null) { rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||||
|
let dbz = Number(val); if (dbz < 0) dbz = 0; if (dbz > 75) dbz = 75;
|
||||||
|
let bin = Math.floor(dbz / 5); if (bin >= 15) bin = 14;
|
||||||
|
rowBins.push(bin); rowCustom.push([r,c,dbz]);
|
||||||
|
}
|
||||||
|
zBins.push(rowBins); custom.push(rowCustom);
|
||||||
|
}
|
||||||
|
const scale = []; for (let i = 0; i < colors.length; i++) { const tpos = colors.length === 1 ? 0 : i / (colors.length - 1); scale.push([tpos, colors[i]]); }
|
||||||
|
const data = [{
|
||||||
|
type: 'heatmap', x: xs, y: ys, z: zBins, customdata: custom,
|
||||||
|
colorscale: scale, zmin: 0, zmax: 14,
|
||||||
|
colorbar: {
|
||||||
|
orientation: 'h', x: 0.5, y: -0.12, xanchor: 'center', yanchor: 'top',
|
||||||
|
len: 0.8, thickness: 16, title: { text: 'dBZ', side: 'bottom' },
|
||||||
|
tickmode: 'array', tickvals: Array.from({length:15}, (_,i)=>i), ticktext: Array.from({length:15}, (_,i)=>String(i*5))
|
||||||
|
},
|
||||||
|
hovertemplate: 'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>'
|
||||||
|
}];
|
||||||
|
// 叠加扇形区域
|
||||||
|
if (gWindFromDeg !== null && gWindSpeedMS > 0 && gStLat !== null && gStLon !== null) {
|
||||||
|
const half = 30, samples = 64, start = gWindFromDeg - half, end = gWindFromDeg + half, rangeM = gWindSpeedMS * 3 * 3600;
|
||||||
|
const xsFan = [gStLon], ysFan = [gStLat];
|
||||||
|
for (let i = 0; i <= samples; i++) {
|
||||||
|
const θ = start + (end - start) * (i / samples);
|
||||||
|
const p = destPoint(gStLat, gStLon, ((θ % 360) + 360) % 360, rangeM);
|
||||||
|
xsFan.push(p.lon); ysFan.push(p.lat);
|
||||||
|
}
|
||||||
|
xsFan.push(gStLon); ysFan.push(gStLat);
|
||||||
|
data.push({ type:'scatter', mode:'lines', x:xsFan, y:ysFan, line:{ color:'#FFFFFF', width:2, dash:'dash' }, fill:'toself', fillcolor:'rgba(255,255,255,0.18)', hoverinfo:'skip', showlegend:false });
|
||||||
|
}
|
||||||
|
Plotly.newPlot('tile_plot', data, {
|
||||||
|
margin:{l:36,r:8,t:8,b:90},
|
||||||
|
xaxis:{title:{text:'经度', standoff: 12}, tickformat:'.2f', constrain:'domain', automargin:true},
|
||||||
|
yaxis:{title:{text:'纬度', standoff: 12}, tickformat:'.2f', showticklabels: true, scaleanchor:'x', scaleratio:1, constrain:'domain', automargin:true}
|
||||||
|
}, {responsive:true, displayModeBar:false}).then(()=>{
|
||||||
|
const el = document.getElementById('tile_plot'); const s = el.clientWidth; Plotly.relayout(el,{height:s});
|
||||||
|
});
|
||||||
|
window.addEventListener('resize',()=>{ const el=document.getElementById('tile_plot'); const s=el.clientWidth; Plotly.relayout(el,{height:s}); Plotly.Plots.resize(el); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 地理与3H预报工具
|
||||||
|
function toRad(d){ return d * Math.PI / 180; }
|
||||||
|
function toDeg(r){ return r * 180 / Math.PI; }
|
||||||
|
function haversine(lat1, lon1, lat2, lon2){
|
||||||
|
const R=6371000; const dLat=toRad(lat2-lat1); const dLon=toRad(lon2-lon1);
|
||||||
|
const a=Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2;
|
||||||
|
const c=2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a)); return R*c;
|
||||||
|
}
|
||||||
|
function bearingDeg(lat1, lon1, lat2, lon2){
|
||||||
|
const φ1=toRad(lat1), φ2=toRad(lat2), Δλ=toRad(lon2-lon1);
|
||||||
|
const y=Math.sin(Δλ)*Math.cos(φ2); const x=Math.cos(φ1)*Math.sin(φ2)-Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ);
|
||||||
|
return (toDeg(Math.atan2(y,x))+360)%360;
|
||||||
|
}
|
||||||
|
function angDiff(a,b){ let d=((a-b+540)%360)-180; return Math.abs(d); }
|
||||||
|
function destPoint(lat, lon, brgDeg, distM){
|
||||||
|
const R=6371000, δ=distM/R, θ=toRad(brgDeg), φ1=toRad(lat), λ1=toRad(lon);
|
||||||
|
const sinφ1=Math.sin(φ1), cosφ1=Math.cos(φ1), sinδ=Math.sin(δ), cosδ=Math.cos(δ);
|
||||||
|
const sinφ2=sinφ1*cosδ+cosφ1*sinδ*Math.cos(θ); const φ2=Math.asin(sinφ2);
|
||||||
|
const y=Math.sin(θ)*sinδ*cosφ1; const x=cosδ-sinφ1*sinφ2; const λ2=λ1+Math.atan2(y,x);
|
||||||
|
return { lat: toDeg(φ2), lon: ((toDeg(λ2)+540)%360)-180 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeCalcSector(){
|
||||||
|
try{
|
||||||
|
if(!gTileValues || !gXs || !gYs || gWindFromDeg===null || gWindSpeedMS===null || gStLat===null || gStLon===null) return;
|
||||||
|
const halfAngle=30; const rangeM=gWindSpeedMS*3*3600; let best=null;
|
||||||
|
const h=gTileValues.length, w=gTileValues[0].length;
|
||||||
|
for(let r=0;r<h;r++){
|
||||||
|
const lat=gYs[r]; for(let c=0;c<w;c++){
|
||||||
|
const val=gTileValues[r][c]; if(val==null) continue; const dbz=Number(val); if(!(dbz>=40)) continue;
|
||||||
|
const lon=gXs[c]; const dist=haversine(gStLat,gStLon,lat,lon); if(dist>rangeM) continue;
|
||||||
|
const brg=bearingDeg(gStLat,gStLon,lat,lon); if(angDiff(brg,gWindFromDeg)>halfAngle) continue;
|
||||||
|
if(!best || dist<best.dist){ best={dist,lat,lon,dbz}; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const statusEl=document.getElementById('sectorStatus'); const detailEl=document.getElementById('sectorDetail');
|
||||||
|
if(!best){ statusEl.textContent='无(≥40 dBZ)'; detailEl.classList.add('hidden'); }
|
||||||
|
else {
|
||||||
|
const etaSec=best.dist/gWindSpeedMS;
|
||||||
|
const base = gTileDT instanceof Date ? gTileDT : new Date();
|
||||||
|
const eta=new Date(base.getTime()+etaSec*1000);
|
||||||
|
const pad=(n)=>String(n).padStart(2,'0'); const etaStr=`${eta.getFullYear()}-${pad(eta.getMonth()+1)}-${pad(eta.getDate())} ${pad(eta.getHours())}:${pad(eta.getMinutes())}`;
|
||||||
|
document.getElementById('sectorDist').textContent=(best.dist/1000).toFixed(1);
|
||||||
|
document.getElementById('sectorETA').textContent=etaStr;
|
||||||
|
document.getElementById('sectorLat').textContent=Number(best.lat).toFixed(4);
|
||||||
|
document.getElementById('sectorLon').textContent=Number(best.lon).toFixed(4);
|
||||||
|
document.getElementById('sectorDBZ').textContent=Number(best.dbz).toFixed(1);
|
||||||
|
statusEl.textContent='三小时内可能有降雨(≥40 dBZ )'; detailEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}catch(e){ document.getElementById('sectorStatus').textContent='风险评估计算失败:'+e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybePlotSquare(){
|
||||||
|
try{
|
||||||
|
if(!gTileValues || !gXs || !gYs || gWindSpeedMS===null || gStLat===null || gStLon===null) return;
|
||||||
|
const R=6371000; const rangeM=gWindSpeedMS*3*3600;
|
||||||
|
const dLat=(rangeM/R)*(180/Math.PI);
|
||||||
|
const dLon=(rangeM/(R*Math.cos(toRad(gStLat))))*(180/Math.PI);
|
||||||
|
const latMin=gStLat-dLat, latMax=gStLat+dLat, lonMin=gStLon-dLon, lonMax=gStLon+dLon;
|
||||||
|
const xs=gXs, ys=gYs; const h=gTileValues.length,w=gTileValues[0].length;
|
||||||
|
const colors=["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
|
||||||
|
const scale=[]; for(let i=0;i<colors.length;i++){ const tpos=colors.length===1?0:i/(colors.length-1); scale.push([tpos,colors[i]]); }
|
||||||
|
const zBins=[], custom=[];
|
||||||
|
for(let r=0;r<h;r++){
|
||||||
|
const row=gTileValues[r]; const rowBins=[], rowCustom=[]; const lat=ys[r];
|
||||||
|
for(let c=0;c<w;c++){
|
||||||
|
const lon=xs[c]; if(lat<latMin||lat>latMax||lon<lonMin||lon>lonMax){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||||
|
const val=row[c]; if(val==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||||
|
let dbz=Number(val); if(dbz<0)dbz=0; if(dbz>75)dbz=75; let bin=Math.floor(dbz/5); if(bin>=15)bin=14;
|
||||||
|
rowBins.push(bin); rowCustom.push([r,c,dbz]);
|
||||||
|
}
|
||||||
|
zBins.push(rowBins); custom.push(rowCustom);
|
||||||
|
}
|
||||||
|
const data=[{ type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom, colorscale:scale, zmin:0, zmax:14,
|
||||||
|
colorbar:{orientation:'h',x:0.5,y:-0.12,xanchor:'center',yanchor:'top',len:0.8,thickness:16,title:{text:'dBZ',side:'bottom'},
|
||||||
|
tickmode:'array',tickvals:Array.from({length:15},(_,i)=>i),ticktext:Array.from({length:15},(_,i)=>String(i*5))},
|
||||||
|
hovertemplate:'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>' }];
|
||||||
|
if(gWindFromDeg!==null && gWindSpeedMS>0){
|
||||||
|
const half=30, samples=64, start=gWindFromDeg-half, end=gWindFromDeg+half;
|
||||||
|
const xsFan=[gStLon], ysFan=[gStLat];
|
||||||
|
for(let i=0;i<=samples;i++){ const θ=start+(end-start)*(i/samples); const p=destPoint(gStLat,gStLon,((θ%360)+360)%360,rangeM); xsFan.push(p.lon); ysFan.push(p.lat); }
|
||||||
|
xsFan.push(gStLon); ysFan.push(gStLat);
|
||||||
|
data.push({ type:'scatter', mode:'lines', x:xsFan, y:ysFan, line:{color:'#FFFFFF',width:2,dash:'dash'}, fill:'toself', fillcolor:'rgba(255,255,255,0.18)', hoverinfo:'skip', showlegend:false });
|
||||||
|
}
|
||||||
|
const el=document.getElementById('squarePlot');
|
||||||
|
const layout={ autosize:true, margin:{l:40,r:8,t:8,b:90},
|
||||||
|
xaxis:{title:'经度', tickformat:'.2f', zeroline:false, constrain:'domain', automargin:true, range:[lonMin, lonMax]},
|
||||||
|
yaxis:{title:{text:'纬度',standoff:12}, tickformat:'.2f', zeroline:false, scaleanchor:'x', scaleratio:1, constrain:'domain', automargin:true, range:[latMin, latMax]},
|
||||||
|
shapes:[{type:'rect',xref:'x',yref:'y',x0:lonMin,x1:lonMax,y0:latMin,y1:latMax,line:{color:'#111',width:1,dash:'dot'},fillcolor:'rgba(0,0,0,0)'}] };
|
||||||
|
Plotly.newPlot(el, data, layout, {responsive:true, displayModeBar:false}).then(()=>{ const s=el.clientWidth; Plotly.relayout(el,{height:s}); });
|
||||||
|
window.addEventListener('resize',()=>{ const s=el.clientWidth; Plotly.relayout(el,{height:s}); Plotly.Plots.resize(el); });
|
||||||
|
}catch(e){ document.getElementById('squarePlot').innerHTML=`<div class=\"p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded\">正方形热力图渲染失败:${e.message}</div>`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await loadStations();
|
||||||
|
// 初始化时间范围为最近24小时
|
||||||
|
(function initRange(){
|
||||||
|
const now = new Date();
|
||||||
|
const end = now; // 到现在
|
||||||
|
const start = new Date(now.getTime() - 6*3600*1000); // 6 小时前
|
||||||
|
const pad = (n)=> String(n).padStart(2,'0');
|
||||||
|
const toLocalInput=(d)=> `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
const sEl = document.getElementById('tsStart');
|
||||||
|
const eEl = document.getElementById('tsEnd');
|
||||||
|
if (sEl && eEl) { sEl.value = toLocalInput(start); eEl.value = toLocalInput(end); }
|
||||||
|
})();
|
||||||
|
document.getElementById('btnLoad').addEventListener('click', async ()=>{
|
||||||
|
const sel = document.getElementById('stationSelect');
|
||||||
|
const alias = sel.value;
|
||||||
|
if (!alias) return;
|
||||||
|
gAlias = alias;
|
||||||
|
gZ = Number(sel.options[sel.selectedIndex].dataset.z || 0);
|
||||||
|
gY = Number(sel.options[sel.selectedIndex].dataset.y || 0);
|
||||||
|
gX = Number(sel.options[sel.selectedIndex].dataset.x || 0);
|
||||||
|
const lat = Number(sel.options[sel.selectedIndex].dataset.lat);
|
||||||
|
const lon = Number(sel.options[sel.selectedIndex].dataset.lon);
|
||||||
|
gStLat = isNaN(lat)? null : lat; gStLon = isNaN(lon)? null : lon;
|
||||||
|
document.getElementById('rt_zyx').textContent = `z=${gZ}, y=${gY}, x=${gX}`;
|
||||||
|
try { await loadRealtime(alias); } catch (e) { console.warn(e); }
|
||||||
|
if (gZ && gY && gX) {
|
||||||
|
const from = fromDTLocalInput(document.getElementById('tsStart').value);
|
||||||
|
const to = fromDTLocalInput(document.getElementById('tsEnd').value);
|
||||||
|
await populateTimes(gZ,gY,gX, from, to);
|
||||||
|
await loadLatestTile(gZ,gY,gX);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tsQuery = document.getElementById('tsQuery');
|
||||||
|
if (tsQuery) tsQuery.addEventListener('click', async ()=>{
|
||||||
|
if (!(gZ && gY && gX)) return;
|
||||||
|
const from = fromDTLocalInput(document.getElementById('tsStart').value);
|
||||||
|
const to = fromDTLocalInput(document.getElementById('tsEnd').value);
|
||||||
|
await populateTimes(gZ,gY,gX, from, to);
|
||||||
|
if (gCurrentIdx >= 0) await loadTileAt(gZ,gY,gX, gTimes[gCurrentIdx]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeSelect = document.getElementById('timeSelect');
|
||||||
|
if (timeSelect) timeSelect.addEventListener('change', async (e)=>{
|
||||||
|
if (!(gZ && gY && gX)) return;
|
||||||
|
const dt = e.target.value;
|
||||||
|
if (!dt) { await loadLatestTile(gZ,gY,gX); return; }
|
||||||
|
gCurrentIdx = gTimes.indexOf(dt);
|
||||||
|
updateCountAndButtons();
|
||||||
|
updateSlider();
|
||||||
|
await loadTileAt(gZ,gY,gX, dt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeSlider = document.getElementById('timeSlider');
|
||||||
|
if (timeSlider) timeSlider.addEventListener('input', async (e)=>{
|
||||||
|
if (!(gZ && gY && gX)) return;
|
||||||
|
const total = gTimes.length;
|
||||||
|
const raw = Number(e.target.value);
|
||||||
|
const idx = (total - 1) - raw; // 右端=最新
|
||||||
|
if (idx >= 0 && idx < gTimes.length) {
|
||||||
|
gCurrentIdx = idx;
|
||||||
|
updateCountAndButtons();
|
||||||
|
const sel = document.getElementById('timeSelect');
|
||||||
|
if (sel) sel.value = gTimes[idx] || '';
|
||||||
|
await loadTileAt(gZ,gY,gX, gTimes[idx]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const btnPrev = document.getElementById('btnPrev');
|
||||||
|
if (btnPrev) btnPrev.addEventListener('click', async ()=>{
|
||||||
|
if (!(gZ && gY && gX)) return;
|
||||||
|
// 上一时次:向更老的时间移动(索引+1)
|
||||||
|
if (gCurrentIdx < gTimes.length - 1) {
|
||||||
|
gCurrentIdx++;
|
||||||
|
updateCountAndButtons();
|
||||||
|
updateSlider();
|
||||||
|
const sel = document.getElementById('timeSelect');
|
||||||
|
if (sel) sel.value = gTimes[gCurrentIdx] || '';
|
||||||
|
await loadTileAt(gZ,gY,gX, gTimes[gCurrentIdx]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const btnNext = document.getElementById('btnNext');
|
||||||
|
if (btnNext) btnNext.addEventListener('click', async ()=>{
|
||||||
|
if (!(gZ && gY && gX)) return;
|
||||||
|
// 下一时次:向更新的时间移动(索引-1)
|
||||||
|
if (gCurrentIdx > 0) {
|
||||||
|
gCurrentIdx--;
|
||||||
|
updateCountAndButtons();
|
||||||
|
updateSlider();
|
||||||
|
const sel = document.getElementById('timeSelect');
|
||||||
|
if (sel) sel.value = gTimes[gCurrentIdx] || '';
|
||||||
|
await loadTileAt(gZ,gY,gX, gTimes[gCurrentIdx]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.addEventListener('DOMContentLoaded', main);
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{ template "header" . }}
|
||||||
|
<div class="content-narrow p-4 text-sm">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="text-lg font-semibold mb-2">雷达站</div>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm">选择站点:</label>
|
||||||
|
<select id="stationSelect" class="border rounded px-2 py-1 text-sm min-w-[280px]"></select>
|
||||||
|
<button id="btnLoad" class="px-2.5 py-1 text-sm bg-blue-600 text-white rounded">加载数据</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm text-gray-700">时间范围:</span>
|
||||||
|
<input id="tsStart" type="datetime-local" class="border rounded px-2 py-1 text-sm"/>
|
||||||
|
<span>至</span>
|
||||||
|
<input id="tsEnd" type="datetime-local" class="border rounded px-2 py-1 text-sm"/>
|
||||||
|
<button id="tsQuery" class="px-2.5 py-1 text-sm bg-blue-600 text-white rounded">查询</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="text-base font-semibold mb-2">气象数据</div>
|
||||||
|
<div id="rtBox" class="text-sm grid grid-cols-2 gap-y-1.5 gap-x-6">
|
||||||
|
<div>站点:<span id="rt_alias"></span></div>
|
||||||
|
<div>Z/Y/X:<span id="rt_zyx"></span></div>
|
||||||
|
<div>时间:<span id="rt_dt"></span></div>
|
||||||
|
<div>位置:<span id="rt_lat"></span>,<span id="rt_lon"></span></div>
|
||||||
|
<div>温度:<span id="rt_t"></span> ℃</div>
|
||||||
|
<div>湿度:<span id="rt_h"></span></div>
|
||||||
|
<div>风速:<span id="rt_ws"></span> m/s</div>
|
||||||
|
<div>风向:<span id="rt_wd"></span> °</div>
|
||||||
|
<div>云量:<span id="rt_c"></span></div>
|
||||||
|
<div>能见度:<span id="rt_vis"></span> km</div>
|
||||||
|
<div>下行短波:<span id="rt_dswrf"></span> W/m²</div>
|
||||||
|
<div>气压:<span id="rt_p"></span> Pa</div>
|
||||||
|
</div>
|
||||||
|
<div id="rt_missing" class="mt-2 hidden p-2 text-sm text-yellow-800 bg-yellow-50 border border-yellow-200 rounded">提示:该时次的 10 分钟实况缺失,已显示最近/最新实况。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card my-4">
|
||||||
|
<div class="text-lg font-semibold mb-2">未来 3H 预报</div>
|
||||||
|
<div id="sectorInfo" class="text-sm">
|
||||||
|
<div id="sectorStatus">计算中…</div>
|
||||||
|
<div id="sectorDetail" class="mt-1 hidden">
|
||||||
|
最近距离:<span id="sectorDist"></span> km;预计到达时间:<span id="sectorETA"></span><br>
|
||||||
|
位置:lat=<span id="sectorLat"></span>,lon=<span id="sectorLon"></span> 组合反射率:<span id="sectorDBZ"></span> dBZ
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="text-base font-semibold mb-2">雷达瓦片</div>
|
||||||
|
<div class="text-sm space-y-1">
|
||||||
|
<div>时间:<span id="tile_dt" class="font-mono"></span></div>
|
||||||
|
<div>索引:z=<span id="tile_z"></span> / y=<span id="tile_y"></span> / x=<span id="tile_x"></span></div>
|
||||||
|
<div>边界:W=<span id="tile_w"></span>,S=<span id="tile_s"></span>,E=<span id="tile_e"></span>,N=<span id="tile_n"></span></div>
|
||||||
|
<div>分辨率(度/像素):<span id="tile_res"></span></div>
|
||||||
|
<div id="tile_status" class="text-gray-500"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
|
||||||
|
<button id="btnPrev" class="px-2.5 py-1 text-sm border rounded">上一时次</button>
|
||||||
|
<span id="countInfo" class="text-gray-600 text-sm">共0条,第0条</span>
|
||||||
|
<button id="btnNext" class="px-2.5 py-1 text-sm border rounded">下一时次</button>
|
||||||
|
<select id="timeSelect" class="border rounded px-2 py-1 text-sm min-w-[240px]">
|
||||||
|
<option value="">最新</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="w-full flex justify-center my-2">
|
||||||
|
<input id="timeSlider" type="range" min="0" max="0" value="0" step="1" class="slider slider-horizontal w-64" />
|
||||||
|
</div>
|
||||||
|
<div id="tile_plot" class="plot-box-sm mt-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="card mt-4" style="width:100%;">
|
||||||
|
<div class="text-lg font-semibold mb-2">正方形裁减区域</div>
|
||||||
|
<div id="squarePlot" class="plot-box"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user