diff --git a/internal/server/gin.go b/internal/server/gin.go
index 244e712..d1b6888 100644
--- a/internal/server/gin.go
+++ b/internal/server/gin.go
@@ -339,13 +339,12 @@ func intFromMeta(m map[string]any, key string) int {
// radarLatestWindHandler queries Caiyun realtime wind for the latest query candidates
// and provides per-cluster aggregated wind and basic coming/ETA analysis toward station.
func radarLatestWindHandler(c *gin.Context) {
- // 使用极坐标法:对每个云团仅在质心取一次风,直接判定靠近与ETA
- // 常量:目标点(站点/雷达点)坐标
+ // Constants per user request
const (
stationLat = 23.097234
stationLon = 108.715433
)
- // 读取最新元数据
+ // Read latest metadata into struct
latestRoot := "./radar_data/latest"
metaPath := latestRoot + "/metadata.json"
b, err := os.ReadFile(metaPath)
@@ -358,8 +357,7 @@ func radarLatestWindHandler(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析元数据失败"})
return
}
-
- // 输出结构保持兼容:仍提供 candidates,但每个cluster仅一个(质心)
+ // For each query candidate, call Caiyun
type Wind struct {
Speed float64 `json:"speed_ms"`
DirFrom float64 `json:"dir_from_deg"`
@@ -375,38 +373,29 @@ func radarLatestWindHandler(c *gin.Context) {
Wind *Wind `json:"wind,omitempty"`
Error string `json:"error,omitempty"`
}
-
- outs := make([]CandOut, 0, len(meta.Clusters))
-
- // 工具函数
- mPerDegLat := 111320.0
- mPerDegLon := func(lat float64) float64 { return 111320.0 * math.Cos(lat*math.Pi/180.0) }
- // 计算极坐标ETA(到站点本身,不再使用侧向与半径作为命中条件)
- approachETA := func(lonC, latC, speedMS, dirToDeg, lonS, latS float64) (coming bool, etaMin float64, distanceKm float64, vrMS float64) {
- wx := mPerDegLon(latC)
- wy := mPerDegLat
- dx := (lonS - lonC) * wx // 东向米
- dy := (latS - latC) * wy // 北向米
- D := math.Hypot(dx, dy)
- if D == 0 {
- return true, 0, 0, speedMS
+ outs := make([]CandOut, 0, len(meta.QueryCandidates))
+ for _, q := range meta.QueryCandidates {
+ speed, dirFrom, tempC, rh, pPa, err := rf.FetchCaiyunRealtime(q.Lon, q.Lat)
+ co := CandOut{QueryCandidate: q}
+ if err != nil {
+ co.Error = err.Error()
+ } else {
+ dirTo := mathMod(dirFrom+180.0, 360.0)
+ u, v := windVectorUV(speed, dirTo)
+ // pressure in hPa for display
+ pHpa := pPa / 100.0
+ co.Wind = &Wind{Speed: speed, DirFrom: dirFrom, DirTo: dirTo, U: u, V: v, TempC: tempC, RH: rh, PressureHpa: pHpa}
}
- // 云→站方位角(北=0,顺时针)
- theta := math.Atan2(dx, dy) * 180 / math.Pi
- if theta < 0 {
- theta += 360
- }
- beta := mathMod(dirToDeg, 360.0)
- delta := (beta - theta) * math.Pi / 180.0
- vr := speedMS * math.Cos(delta) // 指向站点的径向速度
- if vr <= 0 {
- return false, -1, D / 1000.0, vr
- }
- etaSec := D / vr
- return true, etaSec / 60.0, D / 1000.0, vr
+ outs = append(outs, co)
+ }
+ // Aggregate by cluster id
+ agg := map[int][]Wind{}
+ for _, co := range outs {
+ if co.Wind == nil {
+ continue
+ }
+ agg[co.ClusterID] = append(agg[co.ClusterID], *co.Wind)
}
-
- // 为每个云团质心取一次风,构造 candidates 与 per-cluster 分析
type ClusterAnal struct {
ClusterID int `json:"cluster_id"`
Lon float64 `json:"lon"`
@@ -422,64 +411,77 @@ func radarLatestWindHandler(c *gin.Context) {
DistanceKm float64 `json:"distance_km"`
LateralKm float64 `json:"lateral_km"`
RCloudKm float64 `json:"r_cloud_km"`
- VrMS float64 `json:"vr_ms"`
}
analyses := []ClusterAnal{}
-
- // 等效云半径与侧向距离仅用于展示(不再作为判定条件)
- cellDims := func(lat float64) (float64, float64) { // 每像素米宽/米高
+ // helpers
+ mPerDegLat := 111320.0
+ mPerDegLon := func(lat float64) float64 { return 111320.0 * math.Cos(lat*math.Pi/180.0) }
+ cellDims := func(lat float64) (float64, float64) { // width (lon), height (lat) in meters per pixel
return meta.ResDeg * mPerDegLon(lat), meta.ResDeg * mPerDegLat
}
-
+ const hitRadiusM = 5000.0
for _, cl := range meta.Clusters {
- // 取质心风
- speed, dirFrom, tempC, rh, pPa, err := rf.FetchCaiyunRealtime(cl.Lon, cl.Lat)
- q := rf.QueryCandidate{ClusterID: cl.ID, Role: "center", Lon: cl.Lon, Lat: cl.Lat}
- co := CandOut{QueryCandidate: q}
- if err != nil {
- co.Error = err.Error()
- outs = append(outs, co)
- // 即便取风失败,也继续下一个云团
+ winds := agg[cl.ID]
+ if len(winds) == 0 {
continue
}
- dirTo := mathMod(dirFrom+180.0, 360.0)
- u, v := windVectorUV(speed, dirTo)
- pHpa := pPa / 100.0
- co.Wind = &Wind{Speed: speed, DirFrom: dirFrom, DirTo: dirTo, U: u, V: v, TempC: tempC, RH: rh, PressureHpa: pHpa}
- outs = append(outs, co)
-
- // 极坐标法靠近/ETA(到站点)
- coming, etaMin, distKm, vr := approachETA(cl.Lon, cl.Lat, speed, dirTo, stationLon, stationLat)
-
- // 展示参数:侧向距与等效半径
- // 侧向距 = 距离向量对速度方向单位向量的叉积绝对值
- wx, wy := mPerDegLon(cl.Lat), mPerDegLat
- px := (stationLon - cl.Lon) * wx
- py := (stationLat - cl.Lat) * wy
- vnorm := math.Hypot(u, v)
- lateral := 0.0
- if vnorm > 0 {
- vx, vy := u/vnorm, v/vnorm
- lateral = math.Abs(px*vy - py*vx)
+ // vector average in u,v (to-direction)
+ sumU, sumV := 0.0, 0.0
+ for _, wv := range winds {
+ sumU += wv.U
+ sumV += wv.V
}
+ u := sumU / float64(len(winds))
+ v := sumV / float64(len(winds))
+ speed := math.Hypot(u, v)
+ dirTo := uvToDirTo(u, v)
+ // project geometry
+ wx, wy := mPerDegLon(cl.Lat), mPerDegLat
+ // position of cluster and station in meters (local tangent plane)
+ px := (cl.Lon - stationLon) * wx
+ py := (cl.Lat - stationLat) * wy
+ // vector from cluster to station
+ dx := -px
+ dy := -py
+ d := math.Hypot(dx, dy)
+ // radial component of velocity towards station
+ if d == 0 {
+ d = 1e-6
+ }
+ vr := (dx*u + dy*v) / d
+ // cluster equivalent radius
cw, ch := cellDims(cl.Lat)
areaM2 := float64(cl.AreaPx) * cw * ch
rCloud := math.Sqrt(areaM2 / math.Pi)
-
+ // lateral offset (perpendicular distance from station line)
+ vnorm := math.Hypot(u, v)
+ lateral := 0.0
+ if vnorm > 0 {
+ // |d x vhat|
+ vx, vy := u/vnorm, v/vnorm
+ lateral = math.Abs(dx*vy - dy*vx)
+ }
+ coming := vr > 0 && lateral <= (rCloud+hitRadiusM)
+ etaMin := 0.0
+ if coming && vr > 0 {
+ distToEdge := d - (rCloud + hitRadiusM)
+ if distToEdge < 0 {
+ distToEdge = 0
+ }
+ etaMin = distToEdge / vr / 60.0
+ }
analyses = append(analyses, ClusterAnal{
ClusterID: cl.ID,
Lon: cl.Lon, Lat: cl.Lat,
AreaPx: cl.AreaPx, MaxDBZ: cl.MaxDBZ,
SpeedMS: speed, DirToDeg: dirTo, U: u, V: v,
Coming: coming, ETAMin: round2(etaMin),
- DistanceKm: round2(distKm), LateralKm: round2(lateral / 1000.0), RCloudKm: round2(rCloud / 1000.0),
- VrMS: round2(vr),
+ DistanceKm: round2(d / 1000.0), LateralKm: round2(lateral / 1000.0), RCloudKm: round2(rCloud / 1000.0),
})
}
-
c.JSON(http.StatusOK, gin.H{
"station": gin.H{"lon": stationLon, "lat": stationLat},
- "params": meta.QueryParams, // 兼容保留
+ "params": meta.QueryParams,
"candidates": outs,
"clusters": analyses,
})
diff --git a/templates/index.html b/templates/index.html
index e3c6bf4..e59e604 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -720,7 +720,7 @@
window.RadarLatestGrid = data;
renderPlotlyHeat(data);
renderClustersPanel();
- renderMethodNote();
+ renderWindQueryList();
renderWindResults();
}
@@ -838,7 +838,11 @@
+ '质心: '+cl.lon.toFixed(4)+', '+cl.lat.toFixed(4)+'
'
+ 'dBZ: max '+cl.max_dbz.toFixed(1)+' / avg '+cl.avg_dbz.toFixed(1)
+ '';
- // 极坐标法:不再展示采样点列表(仅使用质心)
+ if (cl.samples && cl.samples.length) {
+ html += '
'+code.replace(/' - + '
| cluster | ' + + 'role | ' + + 'lon | ' + + 'lat | ' + + 'spd(m/s) | ' + + 'dir_from(°) | ' + + 'T(°C) | ' + + 'RH | ' + + 'P(hPa) | ' + + 'err | ' + + '
|---|---|---|---|---|---|---|---|---|---|
| '+p.cluster_id+' | ' + + ''+p.role+' | ' + + ''+p.lon.toFixed(4)+' | ' + + ''+p.lat.toFixed(4)+' | ' + + ''+(w.speed_ms!=null?w.speed_ms.toFixed(1):'')+' | ' + + ''+(w.dir_from_deg!=null?w.dir_from_deg.toFixed(0):'')+' | ' + + ''+(w.temp_c!=null?w.temp_c.toFixed(1):'')+' | ' + + ''+(w.rh!=null?(w.rh*100).toFixed(0)+'%':'')+' | ' + + ''+(w.pressure_hpa!=null?w.pressure_hpa.toFixed(1):'')+' | ' + + ''+(p.error||'')+' | ' + + '