diff --git a/internal/server/gin.go b/internal/server/gin.go index a8b9061..ef5fdd6 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -126,6 +126,8 @@ func StartGinServer() error { api.GET("/radar/at", radarTileAtHandler) api.GET("/radar/nearest", nearestRadarTileHandler) api.GET("/radar/times", radarTileTimesHandler) + // multi-tiles at same dt + api.GET("/radar/tiles_at", radarTilesAtHandler) api.GET("/radar/weather_latest", latestRadarWeatherHandler) api.GET("/radar/weather_at", radarWeatherAtHandler) api.GET("/radar/weather_aliases", radarWeatherAliasesHandler) diff --git a/internal/server/radar_api.go b/internal/server/radar_api.go index 2895c0c..88eff59 100644 --- a/internal/server/radar_api.go +++ b/internal/server/radar_api.go @@ -3,6 +3,7 @@ package server import ( "database/sql" "encoding/binary" + "fmt" "math" "net/http" "time" @@ -261,6 +262,98 @@ func radarTileTimesHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"times": times}) } +// radarTilesAtHandler returns all radar tiles at the given dt for a specific z. +// It aggregates rows with the same z and dt but different y/x so the frontend can overlay them together. +// GET /api/radar/tiles_at?z=7&dt=YYYY-MM-DD HH:MM:SS +func radarTilesAtHandler(c *gin.Context) { + z := parseIntDefault(c.Query("z"), 7) + 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 + } + + // Query all tiles at z + dt + const q = ` + SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data + FROM radar_tiles + WHERE z=$1 AND dt=$2 + ORDER BY y, x` + rows, qerr := database.GetDB().Query(q, z, dt) + if qerr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("数据库查询失败: %v", qerr)}) + return + } + defer rows.Close() + + var tiles []radarTileResponse + for rows.Next() { + var r radarTileRecord + if err := rows.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); err != nil { + continue + } + w, h := r.Width, r.Height + if w <= 0 || h <= 0 || len(r.Data) < w*h*2 { + continue + } + 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(r.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 + } + tiles = append(tiles, radarTileResponse{ + DT: r.DT.In(loc).Format("2006-01-02 15:04:05"), + Z: r.Z, + Y: r.Y, + X: r.X, + Width: r.Width, + Height: r.Height, + West: r.West, + South: r.South, + East: r.East, + North: r.North, + ResDeg: r.ResDeg, + Values: vals, + }) + } + + if len(tiles) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "未找到指定时间的雷达瓦片集合"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "dt": dt.In(loc).Format("2006-01-02 15:04:05"), + "tiles": tiles, + }) +} + // nearestRadarTileHandler 返回最接近给定时间的瓦片(支持 z/y/x、容差分钟、偏好 lte 或 nearest) func nearestRadarTileHandler(c *gin.Context) { z := parseIntDefault(c.Query("z"), 7) diff --git a/static/js/weather-app.js b/static/js/weather-app.js index f0c10e7..d8d2b87 100644 --- a/static/js/weather-app.js +++ b/static/js/weather-app.js @@ -80,14 +80,14 @@ const WeatherMap = { }, createTileOverlayLayer(){ - const layer = new ol.layer.Image({ - visible: true, - opacity: 0.8, - source: null, - zIndex: 999 + // 使用分组图层以支持同一时次叠加多块瓦片 + const group = new ol.layer.Group({ + layers: [], + zIndex: 999, + visible: true }); - this.tileOverlayLayer = layer; - return layer; + this.tileOverlayGroup = group; + return group; }, // 创建地图图层 @@ -169,7 +169,7 @@ const WeatherMap = { if (this.tileProduct === 'none') { this.tileTimes = []; this.tileCurrentIdx = -1; - if (this.tileOverlayLayer) this.tileOverlayLayer.setSource(null); + this.clearTileOverlays(); this.updateTileCountInfo(); return; } @@ -268,14 +268,50 @@ const WeatherMap = { async loadAndRenderTile(dtStr){ try{ const z=this.tileZ,y=this.tileY,x=this.tileX; - const url = this.tileProduct==='rain' ? `/api/rain/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}` : `/api/radar/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}`; - const r = await fetch(url); - if(!r.ok){ console.warn('瓦片未找到', dtStr); return; } - const t = await r.json(); - await this.renderTileOnMap(this.tileProduct, t); + if (this.tileProduct === 'rain') { + const url = `/api/rain/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}`; + const r = await fetch(url); + if(!r.ok){ console.warn('雨量瓦片未找到', dtStr); return; } + const t = await r.json(); + await this.renderTileOnMap('rain', t); + } else { + // radar: 取同一时次该 z 下的所有 y/x 瓦片 + const url = `/api/radar/tiles_at?z=${z}&dt=${encodeURIComponent(dtStr)}`; + const r = await fetch(url); + if(!r.ok){ + // 兼容后备:退回单块接口 + const url1 = `/api/radar/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}`; + const r1 = await fetch(url1); + if(!r1.ok){ console.warn('雷达瓦片未找到', dtStr); return; } + const t1 = await r1.json(); + await this.renderTileOnMap('radar', t1); + return; + } + const j = await r.json(); + const tiles = Array.isArray(j.tiles) ? j.tiles : []; + if (tiles.length === 0) { console.warn('该时次无雷达瓦片集合'); this.clearTileOverlays(); return; } + await this.renderTilesOnMap('radar', tiles); + } }catch(e){ console.error('加载/渲染瓦片失败', e); } }, + clearTileOverlays(){ + if (!this.tileOverlayGroup) return; + // 清空子图层 + const coll = this.tileOverlayGroup.getLayers(); + if (coll) coll.clear(); + this.tileLastList = []; + }, + + addImageOverlayFromCanvas(canvas, extent4326){ + const proj = this.map.getView().getProjection(); + const extentProj = ol.proj.transformExtent(extent4326, 'EPSG:4326', proj); + const dataURL = canvas.toDataURL('image/png'); + const src = new ol.source.ImageStatic({ url: dataURL, imageExtent: extentProj, projection: proj }); + const layer = new ol.layer.Image({ source: src, opacity: 0.8, visible: true }); + this.tileOverlayGroup.getLayers().push(layer); + }, + renderTileOnMap(product, t){ if(!t || !t.values) return; const w=t.width, h=t.height, resDeg=t.res_deg; @@ -326,15 +362,53 @@ const WeatherMap = { } } ctx.putImageData(imgData, 0, 0); - // 去除数值文本叠加(应用户要求) - const dataURL = canvas.toDataURL('image/png'); - const extent4326 = [west, south, east, north]; - const proj = this.map.getView().getProjection(); - const extentProj = ol.proj.transformExtent(extent4326, 'EPSG:4326', proj); - const src = new ol.source.ImageStatic({ url: dataURL, imageExtent: extentProj, projection: proj }); - this.tileOverlayLayer.setSource(src); - // 保存最近一次用于拾取 - this.tileLast = { product, meta: { west, south, east, north, resDeg, width:w, height:h }, values: t.values }; + // 清空并添加唯一图层 + this.clearTileOverlays(); + this.addImageOverlayFromCanvas(canvas, [west, south, east, north]); + // 保存用于拾取(单块) + this.tileLastList = [{ product, meta: { west, south, east, north, resDeg, width:w, height:h }, values: t.values }]; + this.setupTileHover(); + }, + + renderTilesOnMap(product, tiles){ + // tiles: radarTileResponse[] + this.clearTileOverlays(); + const lastList = []; + for (const t of tiles){ + if(!t || !t.values) continue; + const w=t.width, h=t.height, resDeg=t.res_deg; + const west=t.west, south=t.south, east=t.east, north=t.north; + const canvas = document.createElement('canvas'); + canvas.width = w; canvas.height = h; + const ctx = canvas.getContext('2d'); + const imgData = ctx.createImageData(w, h); + const colors = [[0,0,255],[0,191,255],[0,255,255],[127,255,212],[124,252,0],[173,255,47],[255,255,0],[255,215,0],[255,165,0],[255,140,0],[255,69,0],[255,0,0],[220,20,60],[199,21,133],[139,0,139]]; + const colorFunc = (dbz)=>{ + let v = Math.max(0, Math.min(75, dbz)); + if (v===0) return [0,0,0,0]; + let bin = Math.floor(v/5); if (bin>=colors.length) bin=colors.length-1; + const c = colors[bin]; return [c[0],c[1],c[2],220]; + }; + for(let row=0; row{ try{ - if (!this.tileLast || !this.tileOverlayLayer || !this.tileOverlayLayer.getSource()) { tip.style.display='none'; return; } + // 需要至少一个叠加瓦片 + if (!this.tileLastList || !this.tileOverlayGroup || this.tileOverlayGroup.getLayers().getLength()===0) { tip.style.display='none'; return; } const coord = this.map.getEventCoordinate(evt.originalEvent); const lonlat = ol.proj.transform(coord, this.map.getView().getProjection(), 'EPSG:4326'); - const {west,south,east,north,resDeg} = this.tileLast.meta; const lon = lonlat[0], lat = lonlat[1]; - if (loneast || latnorth) { tip.style.display='none'; return; } - const col = Math.floor((lon - west)/resDeg); - const row = Math.floor((lat - south)/resDeg); - if (row<0 || row>=this.tileLast.meta.height || col<0 || col>=this.tileLast.meta.width) { tip.style.display='none'; return; } - const v = this.tileLast.values[row]?.[col]; - if (v==null) { tip.style.display='none'; return; } - const val = Number(v); - // 构建文本 - const txt = this.tileLast.product==='rain' ? `${val.toFixed(1)} mm` : `${val.toFixed(1)} dBZ`; + // 选中包含该点的第一块瓦片 + let pickedVal = null, pickedProd = null; + for (const it of this.tileLastList){ + const {west,south,east,north,resDeg,width,height} = it.meta; + if (loneast || latnorth) continue; + const col = Math.floor((lon - west)/resDeg); + const row = Math.floor((lat - south)/resDeg); + if (row<0 || row>=height || col<0 || col>=width) continue; + const v = it.values[row]?.[col]; + if (v==null) continue; + pickedVal = Number(v); pickedProd = it.product; break; + } + if (pickedVal==null){ tip.style.display='none'; return; } + const txt = pickedProd==='rain' ? `${pickedVal.toFixed(1)} mm` : `${pickedVal.toFixed(1)} dBZ`; tip.textContent = txt; // 位置 const pixel = this.map.getPixelFromCoordinate(coord); diff --git a/templates/index.html b/templates/index.html index c4fa18e..69d7f1e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -476,9 +476,9 @@ - - + +