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 @@
+
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) }