feat: 优化地图瓦片显示
This commit is contained in:
parent
08fa1e8a04
commit
7b3ce46f04
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)}`;
|
||||
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; }
|
||||
if(!r.ok){ console.warn('雨量瓦片未找到', dtStr); return; }
|
||||
const t = await r.json();
|
||||
await this.renderTileOnMap(this.tileProduct, t);
|
||||
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<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();
|
||||
},
|
||||
|
||||
@ -344,20 +418,25 @@ const WeatherMap = {
|
||||
if (!tip) return;
|
||||
this.map.on('pointermove', (evt)=>{
|
||||
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 (lon<west || lon>east || lat<south || lat>north) { tip.style.display='none'; return; }
|
||||
// 选中包含该点的第一块瓦片
|
||||
let pickedVal = null, pickedProd = null;
|
||||
for (const it of this.tileLastList){
|
||||
const {west,south,east,north,resDeg,width,height} = it.meta;
|
||||
if (lon<west || lon>east || lat<south || lat>north) continue;
|
||||
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`;
|
||||
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);
|
||||
|
||||
@ -476,9 +476,9 @@
|
||||
<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">英卓 V4</option>
|
||||
<option value="imdroid_mix" selected>英卓 V4</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="cma">中央气象台</option>-->
|
||||
@ -523,11 +523,11 @@
|
||||
<!-- 在时间范围下方增加瓦片联动控制 -->
|
||||
<div class="control-row flex items-center gap-3 flex-wrap">
|
||||
<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">
|
||||
<option value="none" selected>不显示</option>
|
||||
<option value="rain">一小时降雨</option>
|
||||
<option value="radar">组合反射率</option>
|
||||
<option value="rain">上小时降雨</option>
|
||||
<option value="radar">水汽含量</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user