From 12b2ad5ace535ab7e90365d2dd8e8d62c9b81cfc Mon Sep 17 00:00:00 2001 From: yarnom Date: Tue, 23 Sep 2025 16:54:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=B7=E8=BE=BE=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/gin.go | 2 + internal/server/radar_api.go | 135 +++++++++++++++++++++++++++++ templates/radar_guangzhou.html | 90 +++++++++++++++++++- templates/radar_nanning.html | 149 ++++++++++++++++++++++++++++++++- 4 files changed, 369 insertions(+), 7 deletions(-) diff --git a/internal/server/gin.go b/internal/server/gin.go index 905534f..9fc19a4 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -40,6 +40,8 @@ func StartGinServer() error { api.GET("/data", getDataHandler) api.GET("/forecast", getForecastHandler) api.GET("/radar/latest", latestRadarTileHandler) + api.GET("/radar/at", radarTileAtHandler) + api.GET("/radar/times", radarTileTimesHandler) api.GET("/radar/weather_latest", latestRadarWeatherHandler) api.GET("/radar/weather_at", radarWeatherAtHandler) } diff --git a/internal/server/radar_api.go b/internal/server/radar_api.go index ba2d105..ddadd89 100644 --- a/internal/server/radar_api.go +++ b/internal/server/radar_api.go @@ -59,6 +59,23 @@ func getLatestRadarTile(db *sql.DB, z, y, x int) (*radarTileRecord, error) { return &r, nil } +func getRadarTileAt(db *sql.DB, z, y, x int, dt time.Time) (*radarTileRecord, error) { + const q = ` + SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data + FROM radar_tiles + WHERE z=$1 AND y=$2 AND x=$3 AND dt=$4 + LIMIT 1` + var r radarTileRecord + err := db.QueryRow(q, z, y, x, dt).Scan( + &r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height, + &r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data, + ) + if err != nil { + return nil, err + } + return &r, nil +} + // latestRadarTileHandler 返回指定 z/y/x 的最新瓦片,包含栅格 dBZ 值及元数据 func latestRadarTileHandler(c *gin.Context) { // 固定默认 7/40/102,可通过查询参数覆盖 @@ -120,6 +137,124 @@ func latestRadarTileHandler(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// radarTileAtHandler 返回指定 z/y/x 的指定时间瓦片 +func radarTileAtHandler(c *gin.Context) { + z := parseIntDefault(c.Query("z"), 7) + y := parseIntDefault(c.Query("y"), 40) + x := parseIntDefault(c.Query("x"), 102) + dtStr := c.Query("dt") + if dtStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 dt 参数(YYYY-MM-DD HH:MM:SS)"}) + return + } + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = time.FixedZone("CST", 8*3600) + } + dt, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"}) + return + } + rec, err := getRadarTileAt(database.GetDB(), z, y, x, dt) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "未找到指定时间瓦片"}) + return + } + w, h := rec.Width, rec.Height + if len(rec.Data) < w*h*2 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "数据长度异常"}) + return + } + vals := make([][]*float64, h) + off := 0 + for row := 0; row < h; row++ { + rowVals := make([]*float64, w) + for col := 0; col < w; col++ { + v := int16(binary.BigEndian.Uint16(rec.Data[off : off+2])) + off += 2 + if v >= 32766 { + rowVals[col] = nil + continue + } + dbz := float64(v) / 10.0 + if dbz < 0 { + dbz = 0 + } else if dbz > 75 { + dbz = 75 + } + vv := dbz + rowVals[col] = &vv + } + vals[row] = rowVals + } + resp := radarTileResponse{ + DT: rec.DT.Format("2006-01-02 15:04:05"), + Z: rec.Z, + Y: rec.Y, + X: rec.X, + Width: rec.Width, + Height: rec.Height, + West: rec.West, + South: rec.South, + East: rec.East, + North: rec.North, + ResDeg: rec.ResDeg, + Values: vals, + } + c.JSON(http.StatusOK, resp) +} + +// radarTileTimesHandler 返回指定 z/y/x 的可用时间列表(倒序) +func radarTileTimesHandler(c *gin.Context) { + z := parseIntDefault(c.Query("z"), 7) + y := parseIntDefault(c.Query("y"), 40) + x := parseIntDefault(c.Query("x"), 102) + fromStr := c.Query("from") + toStr := c.Query("to") + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = time.FixedZone("CST", 8*3600) + } + var rows *sql.Rows + var err error + if fromStr != "" && toStr != "" { + from, err1 := time.ParseInLocation("2006-01-02 15:04:05", fromStr, loc) + to, err2 := time.ParseInLocation("2006-01-02 15:04:05", toStr, loc) + if err1 != nil || err2 != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 from/to 时间格式"}) + return + } + const qRange = ` + SELECT dt FROM radar_tiles + WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5 + ORDER BY dt DESC` + rows, err = database.GetDB().Query(qRange, z, y, x, from, to) + } else { + limit := parseIntDefault(c.Query("limit"), 48) + const q = ` + SELECT dt FROM radar_tiles + WHERE z=$1 AND y=$2 AND x=$3 + ORDER BY dt DESC + LIMIT $4` + rows, err = database.GetDB().Query(q, z, y, x, limit) + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "查询时间列表失败"}) + return + } + defer rows.Close() + var times []string + for rows.Next() { + var dt time.Time + if err := rows.Scan(&dt); err != nil { + continue + } + times = append(times, dt.Format("2006-01-02 15:04:05")) + } + c.JSON(http.StatusOK, gin.H{"times": times}) +} + func parseIntDefault(s string, def int) int { if s == "" { return def diff --git a/templates/radar_guangzhou.html b/templates/radar_guangzhou.html index 7654611..ca0032c 100644 --- a/templates/radar_guangzhou.html +++ b/templates/radar_guangzhou.html @@ -18,6 +18,22 @@ {{ template "header" . }}
+
+
历史时次查询
+
+
+ + + + +
+
+ +
+
+
最新 7/40/104 瓦片信息
@@ -57,7 +73,16 @@
-
雷达组合反射率
+
+
+ 雷达组合反射率 +
+
+ + 共0条,第0条 + +
+
@@ -74,6 +99,8 @@ let gTileValues = null, gXs = null, gYs = null; let gWindFromDeg = null, gWindSpeedMS = null; + let gTimes = []; + let gCurrentIdx = -1; // 在 gTimes 中的索引(倒序:0=最新) function toRad(d){ return d * Math.PI / 180; } function toDeg(r){ return r * 180 / Math.PI; } @@ -100,6 +127,32 @@ const res = await fetch(`/api/radar/latest?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}`); if(!res.ok) throw new Error('加载最新瓦片失败'); const t = await res.json(); + await renderTile(t); + } + + async function loadTileAt(dtStr){ + const res = await fetch(`/api/radar/at?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}&dt=${encodeURIComponent(dtStr)}`); + if(!res.ok) throw new Error('加载指定时间瓦片失败'); + const t = await res.json(); + await renderTile(t, dtStr); + } + + 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(fromStr, toStr){ + try{ + let url = `/api/radar/times?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_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 && gCurrentIdx<0){ sel.value=gTimes[0]; gCurrentIdx=0; } + updateCountAndButtons(); + }catch{} + } + + async function renderTile(t, forcedDt){ const fmt5 = (n)=>Number(n).toFixed(5); document.getElementById('dt').textContent = t.dt; @@ -112,6 +165,12 @@ document.getElementById('east').textContent = fmt5(t.east); document.getElementById('north').textContent = fmt5(t.north); document.getElementById('res').textContent = fmt5(t.res_deg); + // 标题时间与索引同步 + document.getElementById('titleDt').textContent = `(${t.dt})`; + const selBox = document.getElementById('timeSelect'); + for(let i=0;iString(n).padStart(2,'0'); const dtStr=`${ceil10.getFullYear()}-${pad(ceil10.getMonth()+1)}-${pad(ceil10.getDate())} ${pad(ceil10.getHours())}:${pad(ceil10.getMinutes())}:00`; await loadRealtimeAt(dtStr); maybeCalcSector(); @@ -259,9 +319,31 @@ }catch(e){ document.getElementById('squarePlot').innerHTML=`
正方形热力图渲染失败:${e.message}
`; } } - // 启动加载 - loadLatestTile().catch(err=>{ document.getElementById('radarPlot').innerHTML=`
加载失败:${err.message}
`; }); + // 启动加载:预填近3小时范围并填充时次 + (function initRange(){ const end=new Date(); const start=new Date(end.getTime()-3*3600*1000); document.getElementById('tsStart').value=fmtDTLocal(start); document.getElementById('tsEnd').value=fmtDTLocal(end); })(); + const startStr = fromDTLocalInput(document.getElementById('tsStart').value); + const endStr = fromDTLocalInput(document.getElementById('tsEnd').value); + loadLatestTile().then(()=>populateTimes(startStr, endStr)).catch(err=>{ document.getElementById('radarPlot').innerHTML=`
加载失败:${err.message}
`; }); loadRealtimeLatest().catch(err=>{ document.getElementById('rtInfo').innerHTML=`
${err.message}
`; }); + document.getElementById('timeSelect').addEventListener('change', async (e)=>{ + const v=e.target.value; + if(!v){ if(gTimes.length>0){ gCurrentIdx=0; await loadTileAt(gTimes[0]); } else { gCurrentIdx=-1; await loadLatestTile(); } } + else { gCurrentIdx=gTimes.indexOf(v); await loadTileAt(v); } + updateCountAndButtons(); + }); + document.getElementById('tsQuery').addEventListener('click', async ()=>{ const s=fromDTLocalInput(document.getElementById('tsStart').value); const e=fromDTLocalInput(document.getElementById('tsEnd').value); await populateTimes(s,e); }); + function updateCountAndButtons(){ + const N=gTimes.length; const k=gCurrentIdx>=0?(gCurrentIdx+1):0; document.getElementById('countInfo').textContent=`共${N}条数据,-${k-1}时刻`; + const prev=document.getElementById('btnPrev'); const next=document.getElementById('btnNext'); + prev.disabled = !(N>0 && gCurrentIdx>=0 && gCurrentIdx0 && gCurrentIdx>0); + } + document.getElementById('btnPrev').addEventListener('click', async ()=>{ + if(gTimes.length===0) return; if(gCurrentIdx<0) gCurrentIdx=0; if(gCurrentIdx{ + if(gTimes.length===0) return; if(gCurrentIdx>0){ gCurrentIdx--; const dt=gTimes[gCurrentIdx]; document.getElementById('timeSelect').value=dt; await loadTileAt(dt);} updateCountAndButtons(); + }); diff --git a/templates/radar_nanning.html b/templates/radar_nanning.html index 11df62f..7b63923 100644 --- a/templates/radar_nanning.html +++ b/templates/radar_nanning.html @@ -23,6 +23,23 @@ {{ template "header" . }}
+
+
历史时次查询
+
+
+ + + + +
+
+ +
+
+
+
最新 7/40/102 瓦片信息
@@ -60,7 +77,16 @@
-
雷达组合反射率
+
+
+ 雷达组合反射率 +
+
+ + 共0条,第0条 + +
+
@@ -72,11 +98,63 @@