diff --git a/core/frontend/src/app.component.html b/core/frontend/src/app.component.html
index 6cb6581..95d2ff7 100644
--- a/core/frontend/src/app.component.html
+++ b/core/frontend/src/app.component.html
@@ -73,6 +73,7 @@
@@ -90,6 +91,22 @@
+
+
+
区域强回波统计
+
+ 风向
+ --
+
+
+ 风速
+ --
+
+
≥30 dBZ 0
+
≥35 dBZ 0
+
≥40 dBZ 0
+
+
diff --git a/core/frontend/src/main.ts b/core/frontend/src/main.ts
index 7a830e7..0953099 100644
--- a/core/frontend/src/main.ts
+++ b/core/frontend/src/main.ts
@@ -59,6 +59,7 @@ export class AppComponent implements OnInit, AfterViewInit {
private kmlOverlay: any;
private CLUSTER_THRESHOLD = 10;
private tileOverlayGroup: any;
+ private windOverlayLayer: any;
private tileLastList: any[] = [];
private refreshTimer: any;
private mapEventsBound = false;
@@ -66,7 +67,7 @@ export class AppComponent implements OnInit, AfterViewInit {
tileIndex = -1;
tileZ = 7; tileY = 40; tileX = 102;
tileDt = '';
- tileProduct: 'none'|'radar'|'rain' = 'radar';
+ tileProduct: 'none'|'radar'|'radar_detail'|'rain' = 'radar';
isMapCollapsed = false;
kmlInfoTitle = '';
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.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.windOverlayLayer = new ol.layer.Vector({ source: new ol.source.Vector(), zIndex: 1000, visible: true });
// Load KML overlay from /static/kml/selected_polygons.kml
try {
@@ -159,6 +161,7 @@ export class AppComponent implements OnInit, AfterViewInit {
this.layers.hybrid,
this.kmlLayer,
this.tileOverlayGroup,
+ this.windOverlayLayer,
this.clusterLayer,
this.stationLayer
], 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 {
const params = new URLSearchParams({ z: String(this.tileZ), y: String(this.tileY), x: String(this.tileX) });
// 若指定了开始/结束时间,则按时间范围查询;否则按最近limit条
@@ -339,13 +342,22 @@ export class AppComponent implements OnInit, AfterViewInit {
async renderTilesAt(dt: string) {
try {
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()}`);
if (!r.ok) { this.clearTileOverlays(); return; }
const j = await r.json();
const tiles = Array.isArray(j.tiles) ? j.tiles : [];
- if (tiles.length === 0) { this.clearTileOverlays(); return; }
- await this.renderTilesOnMap(this.tileProduct, tiles);
+ if (tiles.length === 0) { this.clearTileOverlays(); this.clearWindOverlays(); return; }
+ 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(); }
}
@@ -355,6 +367,149 @@ export class AppComponent implements OnInit, AfterViewInit {
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; iy) !== (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= 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]) {
const ol: any = (window as any).ol; if (!ol || !this.map) return;
const proj = this.map.getView().getProjection();
diff --git a/core/internal/data/radar_weather.go b/core/internal/data/radar_weather.go
new file mode 100644
index 0000000..d373769
--- /dev/null
+++ b/core/internal/data/radar_weather.go
@@ -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
+}
diff --git a/core/internal/server/radar_weather_handlers.go b/core/internal/server/radar_weather_handlers.go
new file mode 100644
index 0000000..1cff1ca
--- /dev/null
+++ b/core/internal/server/radar_weather_handlers.go
@@ -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
+}
diff --git a/core/internal/server/router.go b/core/internal/server/router.go
index b05cc3c..ea5a49a 100644
--- a/core/internal/server/router.go
+++ b/core/internal/server/router.go
@@ -51,6 +51,7 @@ func NewRouter(opts Options) *gin.Engine {
api.GET("/forecast/perf", handleForecastPerf)
api.GET("/radar/times", handleRadarTimes)
api.GET("/radar/tiles_at", handleRadarTilesAt)
+ api.GET("/radar/weather_nearest", handleRadarWeatherNearest)
api.GET("/rain/times", handleRainTimes)
api.GET("/rain/tiles_at", handleRainTilesAt)
}