feat: 优化地图瓦片显示

This commit is contained in:
yarnom 2025-10-14 17:49:14 +08:00
parent 08fa1e8a04
commit 7b3ce46f04
4 changed files with 212 additions and 38 deletions

View File

@ -126,6 +126,8 @@ func StartGinServer() error {
api.GET("/radar/at", radarTileAtHandler) api.GET("/radar/at", radarTileAtHandler)
api.GET("/radar/nearest", nearestRadarTileHandler) api.GET("/radar/nearest", nearestRadarTileHandler)
api.GET("/radar/times", radarTileTimesHandler) 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_latest", latestRadarWeatherHandler)
api.GET("/radar/weather_at", radarWeatherAtHandler) api.GET("/radar/weather_at", radarWeatherAtHandler)
api.GET("/radar/weather_aliases", radarWeatherAliasesHandler) api.GET("/radar/weather_aliases", radarWeatherAliasesHandler)

View File

@ -3,6 +3,7 @@ package server
import ( import (
"database/sql" "database/sql"
"encoding/binary" "encoding/binary"
"fmt"
"math" "math"
"net/http" "net/http"
"time" "time"
@ -261,6 +262,98 @@ func radarTileTimesHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"times": times}) 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 // nearestRadarTileHandler 返回最接近给定时间的瓦片(支持 z/y/x、容差分钟、偏好 lte 或 nearest
func nearestRadarTileHandler(c *gin.Context) { func nearestRadarTileHandler(c *gin.Context) {
z := parseIntDefault(c.Query("z"), 7) z := parseIntDefault(c.Query("z"), 7)

View File

@ -80,14 +80,14 @@ const WeatherMap = {
}, },
createTileOverlayLayer(){ createTileOverlayLayer(){
const layer = new ol.layer.Image({ // 使用分组图层以支持同一时次叠加多块瓦片
visible: true, const group = new ol.layer.Group({
opacity: 0.8, layers: [],
source: null, zIndex: 999,
zIndex: 999 visible: true
}); });
this.tileOverlayLayer = layer; this.tileOverlayGroup = group;
return layer; return group;
}, },
// 创建地图图层 // 创建地图图层
@ -169,7 +169,7 @@ const WeatherMap = {
if (this.tileProduct === 'none') { if (this.tileProduct === 'none') {
this.tileTimes = []; this.tileTimes = [];
this.tileCurrentIdx = -1; this.tileCurrentIdx = -1;
if (this.tileOverlayLayer) this.tileOverlayLayer.setSource(null); this.clearTileOverlays();
this.updateTileCountInfo(); this.updateTileCountInfo();
return; return;
} }
@ -268,14 +268,50 @@ const WeatherMap = {
async loadAndRenderTile(dtStr){ async loadAndRenderTile(dtStr){
try{ try{
const z=this.tileZ,y=this.tileY,x=this.tileX; 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)}`; if (this.tileProduct === 'rain') {
const r = await fetch(url); const url = `/api/rain/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}`;
if(!r.ok){ console.warn('瓦片未找到', dtStr); return; } const r = await fetch(url);
const t = await r.json(); if(!r.ok){ console.warn('雨量瓦片未找到', dtStr); return; }
await this.renderTileOnMap(this.tileProduct, t); 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); } }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){ renderTileOnMap(product, t){
if(!t || !t.values) return; if(!t || !t.values) return;
const w=t.width, h=t.height, resDeg=t.res_deg; const w=t.width, h=t.height, resDeg=t.res_deg;
@ -326,15 +362,53 @@ const WeatherMap = {
} }
} }
ctx.putImageData(imgData, 0, 0); ctx.putImageData(imgData, 0, 0);
// 去除数值文本叠加(应用户要求) // 清空并添加唯一图层
const dataURL = canvas.toDataURL('image/png'); this.clearTileOverlays();
const extent4326 = [west, south, east, north]; this.addImageOverlayFromCanvas(canvas, [west, south, east, north]);
const proj = this.map.getView().getProjection(); // 保存用于拾取(单块)
const extentProj = ol.proj.transformExtent(extent4326, 'EPSG:4326', proj); this.tileLastList = [{ product, meta: { west, south, east, north, resDeg, width:w, height:h }, values: t.values }];
const src = new ol.source.ImageStatic({ url: dataURL, imageExtent: extentProj, projection: proj }); this.setupTileHover();
this.tileOverlayLayer.setSource(src); },
// 保存最近一次用于拾取
this.tileLast = { product, meta: { west, south, east, north, resDeg, width:w, height:h }, values: t.values }; 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<h; row++){
const srcRow = t.values[row] || [];
const dstRow = (h-1-row);
for(let col=0; col<w; col++){
const v = srcRow[col];
let rgba;
if (v==null){ rgba=[0,0,0,0]; }
else { rgba = colorFunc(Number(v)); }
const off = (dstRow*w + col)*4;
imgData.data[off+0]=rgba[0];
imgData.data[off+1]=rgba[1];
imgData.data[off+2]=rgba[2];
imgData.data[off+3]=rgba[3];
}
}
ctx.putImageData(imgData, 0, 0);
this.addImageOverlayFromCanvas(canvas, [west, south, east, north]);
lastList.push({ product, meta: { west, south, east, north, resDeg, width:w, height:h }, values: t.values });
}
this.tileLastList = lastList;
this.setupTileHover(); this.setupTileHover();
}, },
@ -344,20 +418,25 @@ const WeatherMap = {
if (!tip) return; if (!tip) return;
this.map.on('pointermove', (evt)=>{ this.map.on('pointermove', (evt)=>{
try{ 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 coord = this.map.getEventCoordinate(evt.originalEvent);
const lonlat = ol.proj.transform(coord, this.map.getView().getProjection(), 'EPSG:4326'); 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]; const lon = lonlat[0], lat = lonlat[1];
if (lon<west || lon>east || lat<south || lat>north) { tip.style.display='none'; return; } // 选中包含该点的第一块瓦片
const col = Math.floor((lon - west)/resDeg); let pickedVal = null, pickedProd = null;
const row = Math.floor((lat - south)/resDeg); for (const it of this.tileLastList){
if (row<0 || row>=this.tileLast.meta.height || col<0 || col>=this.tileLast.meta.width) { tip.style.display='none'; return; } const {west,south,east,north,resDeg,width,height} = it.meta;
const v = this.tileLast.values[row]?.[col]; if (lon<west || lon>east || lat<south || lat>north) continue;
if (v==null) { tip.style.display='none'; return; } const col = Math.floor((lon - west)/resDeg);
const val = Number(v); const row = Math.floor((lat - south)/resDeg);
// 构建文本 if (row<0 || row>=height || col<0 || col>=width) continue;
const txt = this.tileLast.product==='rain' ? `${val.toFixed(1)} mm` : `${val.toFixed(1)} dBZ`; 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; tip.textContent = txt;
// 位置 // 位置
const pixel = this.map.getPixelFromCoordinate(coord); const pixel = this.map.getPixelFromCoordinate(coord);

View File

@ -476,9 +476,9 @@
<label for="forecastProvider" class="text-sm text-gray-600">预报源:</label> <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"> <select id="forecastProvider" class="px-2 py-1 border border-gray-300 rounded text-sm">
<option value="">不显示预报</option> <option value="">不显示预报</option>
<option value="imdroid_mix">英卓 V4</option> <option value="imdroid_mix" selected>英卓 V4</option>
<option value="open-meteo">英卓 V3</option> <option value="open-meteo">英卓 V3</option>
<option value="caiyun" selected>英卓 V2</option> <option value="caiyun">英卓 V2</option>
<option value="imdroid">英卓 V1</option> <option value="imdroid">英卓 V1</option>
<!-- <option value="cma">中央气象台</option>--> <!-- <option value="cma">中央气象台</option>-->
@ -523,11 +523,11 @@
<!-- 在时间范围下方增加瓦片联动控制 --> <!-- 在时间范围下方增加瓦片联动控制 -->
<div class="control-row flex items-center gap-3 flex-wrap"> <div class="control-row flex items-center gap-3 flex-wrap">
<div class="control-group"> <div class="control-group">
<label class="text-sm text-gray-600">地图产品:</label> <label class="text-sm text-gray-600">叠加显示:</label>
<select id="tileProduct" class="px-2 py-1 border border-gray-300 rounded text-sm"> <select id="tileProduct" class="px-2 py-1 border border-gray-300 rounded text-sm">
<option value="none" selected>不显示</option> <option value="none" selected>不显示</option>
<option value="rain">小时降雨</option> <option value="rain">小时降雨</option>
<option value="radar">组合反射率</option> <option value="radar">水汽含量</option>
</select> </select>
</div> </div>
<div class="control-group"> <div class="control-group">