feat: 新增扇形和圆形区域的统计

This commit is contained in:
yarnom 2025-11-20 16:25:17 +08:00
parent c420608387
commit 4efc4cc3ce
5 changed files with 292 additions and 5 deletions

View File

@ -73,6 +73,7 @@
<select class="px-2 py-1 border rounded text-sm" [(ngModel)]="tileProduct" (change)="onProductChange()"> <select class="px-2 py-1 border rounded text-sm" [(ngModel)]="tileProduct" (change)="onProductChange()">
<option value="none">不显示</option> <option value="none">不显示</option>
<option value="radar">水汽含量</option> <option value="radar">水汽含量</option>
<option value="radar_detail">水汽含量(详细)</option>
<option value="rain">1h 实际降雨</option> <option value="rain">1h 实际降雨</option>
</select> </select>
@ -90,6 +91,22 @@
<div id="mapContainer" class="rounded border mb-4" [ngClass]="{ 'collapsed': isMapCollapsed }" [style.borderColor]="'#ddd'" style="position:relative; overflow:hidden;" [style.height]="isMapCollapsed ? '38vh' : '60vh'"> <div id="mapContainer" class="rounded border mb-4" [ngClass]="{ 'collapsed': isMapCollapsed }" [style.borderColor]="'#ddd'" style="position:relative; overflow:hidden;" [style.height]="isMapCollapsed ? '38vh' : '60vh'">
<div id="map" style="width:100%; height:100%;"></div> <div id="map" style="width:100%; height:100%;"></div>
<button class="map-toggle-btn bg-blue-600 hover:bg-blue-700 text-white" style="position:absolute;top:10px;right:10px;z-index:1001;border-radius:4px;padding:5px 10px;font-size:12px;font-weight:bold;" (click)="toggleMap()">{{ isMapCollapsed ? '展开地图' : '折叠地图' }}</button> <button class="map-toggle-btn bg-blue-600 hover:bg-blue-700 text-white" style="position:absolute;top:10px;right:10px;z-index:1001;border-radius:4px;padding:5px 10px;font-size:12px;font-weight:bold;" (click)="toggleMap()">{{ isMapCollapsed ? '展开地图' : '折叠地图' }}</button>
<div id="regionStats" style="position:absolute;top:50px;right:10px;z-index:1002;display:none;">
<div style="background:#ffffff; border:1px solid #ddd; border-radius:4px; padding:8px 10px; font-size:12px; color:#111; box-shadow:0 2px 6px rgba(0,0,0,0.08); min-width: 160px;">
<div style="font-weight:700; margin-bottom:6px;">区域强回波统计</div>
<div style="margin-bottom:4px;">
<span style="display:inline-block; width:64px;">风向</span>
<span id="statWindDir">--</span>
</div>
<div style="margin-bottom:4px;">
<span style="display:inline-block; width:64px;">风速</span>
<span id="statWindSpd">--</span>
</div>
<div><span style="display:inline-block; width:64px;">≥30 dBZ</span> <span id="statDbz30">0</span></div>
<div><span style="display:inline-block; width:64px;">≥35 dBZ</span> <span id="statDbz35">0</span></div>
<div><span style="display:inline-block; width:64px;">≥40 dBZ</span> <span id="statDbz40">0</span></div>
</div>
</div>
<div id="tileValueTooltip" style="position:absolute;pointer-events:none;z-index:1003;display:none;background:rgba(0,0,0,0.65);color:#fff;font-size:12px;padding:4px 6px;border-radius:4px;"></div> <div id="tileValueTooltip" style="position:absolute;pointer-events:none;z-index:1003;display:none;background:rgba(0,0,0,0.65);color:#fff;font-size:12px;padding:4px 6px;border-radius:4px;"></div>
</div> </div>

View File

@ -59,6 +59,7 @@ export class AppComponent implements OnInit, AfterViewInit {
private kmlOverlay: any; private kmlOverlay: any;
private CLUSTER_THRESHOLD = 10; private CLUSTER_THRESHOLD = 10;
private tileOverlayGroup: any; private tileOverlayGroup: any;
private windOverlayLayer: any;
private tileLastList: any[] = []; private tileLastList: any[] = [];
private refreshTimer: any; private refreshTimer: any;
private mapEventsBound = false; private mapEventsBound = false;
@ -66,7 +67,7 @@ export class AppComponent implements OnInit, AfterViewInit {
tileIndex = -1; tileIndex = -1;
tileZ = 7; tileY = 40; tileX = 102; tileZ = 7; tileY = 40; tileX = 102;
tileDt = ''; tileDt = '';
tileProduct: 'none'|'radar'|'rain' = 'radar'; tileProduct: 'none'|'radar'|'radar_detail'|'rain' = 'radar';
isMapCollapsed = false; isMapCollapsed = false;
kmlInfoTitle = ''; kmlInfoTitle = '';
kmlInfoHtml = ''; kmlInfoHtml = '';
@ -142,6 +143,7 @@ export class AppComponent implements OnInit, AfterViewInit {
this.clusterLayer = new ol.layer.Vector({ source: this.clusterSource, style: (f:any)=> this.createClusterStyle(f) }); this.clusterLayer = new ol.layer.Vector({ source: this.clusterSource, style: (f:any)=> this.createClusterStyle(f) });
this.stationLayer = new ol.layer.Vector({ source: this.stationSource, visible: false, style: (f:any)=> this.createStationStyle(f) }); this.stationLayer = new ol.layer.Vector({ source: this.stationSource, visible: false, style: (f:any)=> this.createStationStyle(f) });
this.tileOverlayGroup = new ol.layer.Group({ layers: [], zIndex: 999, visible: true }); this.tileOverlayGroup = new ol.layer.Group({ layers: [], zIndex: 999, visible: true });
this.windOverlayLayer = new ol.layer.Vector({ source: new ol.source.Vector(), zIndex: 1000, visible: true });
// Load KML overlay from /static/kml/selected_polygons.kml // Load KML overlay from /static/kml/selected_polygons.kml
try { try {
@ -159,6 +161,7 @@ export class AppComponent implements OnInit, AfterViewInit {
this.layers.hybrid, this.layers.hybrid,
this.kmlLayer, this.kmlLayer,
this.tileOverlayGroup, this.tileOverlayGroup,
this.windOverlayLayer,
this.clusterLayer, this.clusterLayer,
this.stationLayer this.stationLayer
], view: new ol.View({ center: ol.proj.fromLonLat([108, 35]), zoom: 5, minZoom: 3, maxZoom: 18 }) }); ], view: new ol.View({ center: ol.proj.fromLonLat([108, 35]), zoom: 5, minZoom: 3, maxZoom: 18 }) });
@ -314,7 +317,7 @@ export class AppComponent implements OnInit, AfterViewInit {
} }
} }
async loadTileTimes(product: 'radar'|'rain') { async loadTileTimes(product: 'radar'|'rain'|'radar_detail') {
try { try {
const params = new URLSearchParams({ z: String(this.tileZ), y: String(this.tileY), x: String(this.tileX) }); const params = new URLSearchParams({ z: String(this.tileZ), y: String(this.tileY), x: String(this.tileX) });
// 若指定了开始/结束时间则按时间范围查询否则按最近limit条 // 若指定了开始/结束时间则按时间范围查询否则按最近limit条
@ -339,13 +342,22 @@ export class AppComponent implements OnInit, AfterViewInit {
async renderTilesAt(dt: string) { async renderTilesAt(dt: string) {
try { try {
const params = new URLSearchParams({ z: String(this.tileZ), dt: dt }); const params = new URLSearchParams({ z: String(this.tileZ), dt: dt });
const path = this.tileProduct==='rain' ? '/api/rain/tiles_at' : '/api/radar/tiles_at'; const isRain = this.tileProduct === 'rain';
const path = isRain ? '/api/rain/tiles_at' : '/api/radar/tiles_at';
const r = await fetch(`${path}?${params.toString()}`); const r = await fetch(`${path}?${params.toString()}`);
if (!r.ok) { this.clearTileOverlays(); return; } if (!r.ok) { this.clearTileOverlays(); return; }
const j = await r.json(); const j = await r.json();
const tiles = Array.isArray(j.tiles) ? j.tiles : []; const tiles = Array.isArray(j.tiles) ? j.tiles : [];
if (tiles.length === 0) { this.clearTileOverlays(); return; } if (tiles.length === 0) { this.clearTileOverlays(); this.clearWindOverlays(); return; }
await this.renderTilesOnMap(this.tileProduct, tiles); await this.renderTilesOnMap(isRain ? 'rain' : 'radar', tiles);
// 同步 tileIndex 以匹配选择的时次
const idx = this.tileTimes.findIndex(t => t === dt);
if (idx >= 0) { this.tileIndex = idx; this.tileDt = dt; }
if (!isRain && this.tileProduct === 'radar_detail') {
this.drawWindOverlays(dt);
} else {
this.clearWindOverlays();
}
} catch { this.clearTileOverlays(); } } catch { this.clearTileOverlays(); }
} }
@ -355,6 +367,149 @@ export class AppComponent implements OnInit, AfterViewInit {
if (coll) coll.clear(); if (coll) coll.clear();
} }
clearWindOverlays() {
const ol: any = (window as any).ol; if (!ol || !this.windOverlayLayer) return;
const src = this.windOverlayLayer.getSource();
if (src) src.clear();
try { const box = document.getElementById('regionStats'); if (box) box.style.display='none'; } catch {}
}
private getSelectedStation(): Station | undefined {
try {
const sid = this.makeStationIdFromHex(this.decimalId || '');
if (!sid) return undefined;
return this.stations.find(s => s.station_id === sid);
} catch { return undefined; }
}
private async drawWindOverlays(dtStr: string) {
const ol: any = (window as any).ol; if (!ol || !this.map || !this.windOverlayLayer) return;
const src = this.windOverlayLayer.getSource(); if (!src) return;
src.clear();
// 选择中心:优先当前选中站点,否则地图中心
let centerLon = 108, centerLat = 35;
const st = this.getSelectedStation();
if (st && typeof st.longitude==='number' && typeof st.latitude==='number') { centerLon = st.longitude!; centerLat = st.latitude!; }
else {
try {
const c = this.map.getView().getCenter();
const lonlat = ol.proj.toLonLat(c, this.map.getView().getProjection());
centerLon = lonlat[0]; centerLat = lonlat[1];
} catch {}
}
// 调用后端根据经纬度与时间查询最近的 radar_weather 风数据
let windDir: number|null = null, windSpd: number|null = null;
try {
const params = new URLSearchParams({ lat: String(centerLat), lon: String(centerLon), dt: dtStr });
const r = await fetch(`/api/radar/weather_nearest?${params.toString()}`);
if (r.ok) {
const j = await r.json();
if (j && j.wind_direction != null) windDir = Number(j.wind_direction);
if (j && j.wind_speed != null) windSpd = Number(j.wind_speed);
if (isFinite(windDir as any)) windDir = ((windDir as number) % 360 + 360) % 360;
if (!isFinite(windSpd as any)) windSpd = null;
}
} catch {}
const proj = this.map.getView().getProjection();
const center = ol.proj.fromLonLat([centerLon, centerLat], proj);
// 8km 圆(红色虚线边框)
try {
const circle = new ol.geom.Circle(center, 8000);
const cf = new ol.Feature({ geometry: circle });
cf.setStyle(new ol.style.Style({ stroke: new ol.style.Stroke({ color: 'rgba(255,0,0,0.95)', width: 2, lineDash: [6,4] }), fill: new ol.style.Fill({ color: 'rgba(255,0,0,0.03)' }) }));
src.addFeature(cf);
} catch {}
if (windDir != null && windSpd != null && windSpd > 0.01) {
const bearingTo = (windDir + 180) % 360; // 去向
const hours = 3;
const radius = windSpd * 3600 * hours; // m
const half = 25; // 半角
const pts: number[][] = [];
pts.push(center);
for (let a = -half; a <= half; a += 2.5) {
const ang = (bearingTo + a) * Math.PI / 180;
const dx = radius * Math.sin(ang);
const dy = radius * Math.cos(ang);
pts.push([center[0] + dx, center[1] + dy]);
}
pts.push(center);
const poly = new ol.geom.Polygon([pts]);
const pf = new ol.Feature({ geometry: poly });
pf.setStyle(new ol.style.Style({ stroke: new ol.style.Stroke({ color: 'rgba(255,0,0,0.95)', width: 2, lineDash: [6,4] }), fill: new ol.style.Fill({ color: 'rgba(255,0,0,0.05)' }) }));
src.addFeature(pf);
// 统计扇形区域内的强回波像元数量(基于当前已加载的雷达瓦片)
try {
const polyLonLat: [number,number][] = pts.map(p => {
const lonlat = ol.proj.toLonLat(p, this.map.getView().getProjection());
return [lonlat[0], lonlat[1]];
});
const counts = this.countDbzInPolygon(polyLonLat);
this.updateRegionStats(counts, windDir, windSpd);
} catch {}
}
}
private updateRegionStats(counts?: { ge30:number; ge35:number; ge40:number } | null, windDir?: number|null, windSpd?: number|null) {
try {
const box = document.getElementById('regionStats');
const s30 = document.getElementById('statDbz30');
const s35 = document.getElementById('statDbz35');
const s40 = document.getElementById('statDbz40');
const sDir = document.getElementById('statWindDir');
const sSpd = document.getElementById('statWindSpd');
if (!box || !s30 || !s35 || !s40 || !sDir || !sSpd) return;
if (!counts) { box.style.display='none'; return; }
s30.textContent = String(counts.ge30);
s35.textContent = String(counts.ge35);
s40.textContent = String(counts.ge40);
if (windDir != null && isFinite(windDir)) { sDir.textContent = String(Math.round(windDir)); } else { sDir.textContent = '--'; }
if (windSpd != null && isFinite(windSpd)) { sSpd.textContent = String(Math.round((windSpd as number)*10)/10); } else { sSpd.textContent = '--'; }
box.style.display = 'block';
} catch {}
}
private countDbzInPolygon(poly: [number,number][]): { ge30:number; ge35:number; ge40:number } | null {
// 需要已有雷达瓦片数据
const tiles = (this.tileLastList || []).filter(it => it && it.product==='radar');
if (!tiles.length) return null;
const ptInPoly = (x:number,y:number, polygon:[number,number][]) => {
// ray casting
let inside = false;
for (let i=0, j=polygon.length-1; i<polygon.length; j=i++) {
const xi = polygon[i][0], yi = polygon[i][1];
const xj = polygon[j][0], yj = polygon[j][1];
const intersect = ((yi>y) !== (yj>y)) && (x < (xj - xi) * (y - yi) / ((yj - yi) || 1e-9) + xi);
if (intersect) inside = !inside;
}
return inside;
};
let c30=0, c35=0, c40=0;
for (const t of tiles) {
const { west, south, east, north, width, height } = t.meta;
const dlon = (east - west) / width;
const dlat = (north - south) / height;
const vals: (number|null)[][] = t.values || [];
for (let row=0; row<height; row++) {
const lat = south + (row + 0.5) * dlat;
const srcRow = vals[row] as (number|null)[];
if (!srcRow) continue;
for (let col=0; col<width; col++) {
const v = srcRow[col];
if (v == null || v < 30) continue; // 低于30无需落点判断
const lon = west + (col + 0.5) * dlon;
if (!ptInPoly(lon, lat, poly)) continue;
if (v >= 30) c30++;
if (v >= 35) c35++;
if (v >= 40) c40++;
}
}
}
return { ge30: c30, ge35: c35, ge40: c40 };
}
addImageOverlayFromCanvas(canvas: HTMLCanvasElement, extent4326: [number,number,number,number]) { addImageOverlayFromCanvas(canvas: HTMLCanvasElement, extent4326: [number,number,number,number]) {
const ol: any = (window as any).ol; if (!ol || !this.map) return; const ol: any = (window as any).ol; if (!ol || !this.map) return;
const proj = this.map.getView().getProjection(); const proj = this.map.getView().getProjection();

View File

@ -0,0 +1,45 @@
package data
import (
"database/sql"
"time"
)
type RadarWeather struct {
Alias string
Lat float64
Lon float64
DT time.Time
Temperature sql.NullFloat64
Humidity sql.NullFloat64
CloudRate sql.NullFloat64
Visibility sql.NullFloat64
DSWRF sql.NullFloat64
WindSpeed sql.NullFloat64
WindDirection sql.NullFloat64
Pressure sql.NullFloat64
}
// RadarWeatherNearest returns the nearest radar_weather row to (lat,lon) around dt within a window.
// It orders by absolute time difference then squared distance.
func RadarWeatherNearest(lat, lon float64, dt time.Time, window time.Duration) (*RadarWeather, error) {
from := dt.Add(-window)
to := dt.Add(window)
const q = `
SELECT alias, lat, lon, dt, temperature, humidity, cloudrate, visibility, dswrf,
wind_speed, wind_direction, pressure
FROM radar_weather
WHERE dt BETWEEN $1 AND $2
ORDER BY ABS(EXTRACT(EPOCH FROM (dt - $3))) ASC,
((lat - $4)*(lat - $4) + (lon - $5)*(lon - $5)) ASC
LIMIT 1`
row := DB().QueryRow(q, from, to, dt, lat, lon)
var rw RadarWeather
if err := row.Scan(&rw.Alias, &rw.Lat, &rw.Lon, &rw.DT, &rw.Temperature, &rw.Humidity, &rw.CloudRate, &rw.Visibility, &rw.DSWRF, &rw.WindSpeed, &rw.WindDirection, &rw.Pressure); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &rw, nil
}

View File

@ -0,0 +1,69 @@
package server
import (
"database/sql"
"net/http"
"strconv"
"time"
"weatherstation/core/internal/data"
"github.com/gin-gonic/gin"
)
// GET /api/radar/weather_nearest?lat=..&lon=..&dt=YYYY-MM-DD HH:MM:SS
func handleRadarWeatherNearest(c *gin.Context) {
latStr := c.Query("lat")
lonStr := c.Query("lon")
if latStr == "" || lonStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing lat/lon"})
return
}
lat, err1 := strconv.ParseFloat(latStr, 64)
lon, err2 := strconv.ParseFloat(lonStr, 64)
if err1 != nil || err2 != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid lat/lon"})
return
}
dtStr := c.Query("dt")
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.FixedZone("CST", 8*3600)
}
dt := time.Now().In(loc)
if dtStr != "" {
if x, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc); err == nil {
dt = x
}
}
// search window +/- 6h
rw, err := data.RadarWeatherNearest(lat, lon, dt, 6*time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
if rw == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"alias": rw.Alias,
"lat": rw.Lat,
"lon": rw.Lon,
"dt": rw.DT.In(loc).Format("2006-01-02 15:04:05"),
"temperature": f64(rw.Temperature),
"humidity": f64(rw.Humidity),
"cloudrate": f64(rw.CloudRate),
"visibility": f64(rw.Visibility),
"dswrf": f64(rw.DSWRF),
"wind_speed": f64(rw.WindSpeed),
"wind_direction": f64(rw.WindDirection),
"pressure": f64(rw.Pressure),
})
}
func f64(v sql.NullFloat64) interface{} {
if v.Valid {
return v.Float64
}
return nil
}

View File

@ -51,6 +51,7 @@ func NewRouter(opts Options) *gin.Engine {
api.GET("/forecast/perf", handleForecastPerf) api.GET("/forecast/perf", handleForecastPerf)
api.GET("/radar/times", handleRadarTimes) api.GET("/radar/times", handleRadarTimes)
api.GET("/radar/tiles_at", handleRadarTilesAt) api.GET("/radar/tiles_at", handleRadarTilesAt)
api.GET("/radar/weather_nearest", handleRadarWeatherNearest)
api.GET("/rain/times", handleRainTimes) api.GET("/rain/times", handleRainTimes)
api.GET("/rain/tiles_at", handleRainTilesAt) api.GET("/rain/tiles_at", handleRainTilesAt)
} }