Compare commits
2 Commits
ff9ab1f6c2
...
4fa9822405
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fa9822405 | |||
| 448b13c2f6 |
47
cmd/radar/main.go
Normal file
47
cmd/radar/main.go
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"time"
|
||||||
|
"weatherstation/internal/radarfetch"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var outRoot string
|
||||||
|
var interval time.Duration
|
||||||
|
var tz int
|
||||||
|
var z, y, x int
|
||||||
|
var chinaURL, huananURL, nanningURL, cmaBase string
|
||||||
|
|
||||||
|
flag.StringVar(&outRoot, "out-root", "./radar_data", "output root directory for radar data")
|
||||||
|
flag.DurationVar(&interval, "interval", 10*time.Minute, "download interval")
|
||||||
|
flag.IntVar(&tz, "tz-offset", 8, "local tz offset to UTC (hours)")
|
||||||
|
flag.IntVar(&z, "z", 7, "tile z")
|
||||||
|
flag.IntVar(&y, "y", 40, "tile y")
|
||||||
|
flag.IntVar(&x, "x", 102, "tile x")
|
||||||
|
flag.StringVar(&chinaURL, "nmc-china-url", "https://www.nmc.cn/publish/radar/chinaall.html", "NMC China page URL")
|
||||||
|
flag.StringVar(&huananURL, "nmc-huanan-url", "https://www.nmc.cn/publish/radar/huanan.html", "NMC Huanan page URL")
|
||||||
|
flag.StringVar(&nanningURL, "nmc-nanning-url", "https://www.nmc.cn/publish/radar/guang-xi/nan-ning.htm", "NMC Nanning page URL")
|
||||||
|
flag.StringVar(&cmaBase, "cma-base", "https://image.data.cma.cn", "CMA base URL")
|
||||||
|
var once bool
|
||||||
|
flag.BoolVar(&once, "once", false, "run a single cycle and exit")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
opts := radarfetch.Options{
|
||||||
|
OutRoot: outRoot,
|
||||||
|
TZOffset: tz,
|
||||||
|
Interval: interval,
|
||||||
|
NMCChinaURL: chinaURL,
|
||||||
|
NMCHuananURL: huananURL,
|
||||||
|
NMCNanningURL: nanningURL,
|
||||||
|
CMABase: cmaBase,
|
||||||
|
Z: z,
|
||||||
|
Y: y,
|
||||||
|
X: x,
|
||||||
|
}
|
||||||
|
if once {
|
||||||
|
_ = radarfetch.RunOnce(opts)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
radarfetch.Run(opts)
|
||||||
|
}
|
||||||
58
internal/radarfetch/caiyun.go
Normal file
58
internal/radarfetch/caiyun.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package radarfetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Caiyun token and endpoint (fixed per user instruction)
|
||||||
|
const caiyunToken = "ZAcZq49qzibr10F0"
|
||||||
|
|
||||||
|
type caiyunRealtimeResp struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Result struct {
|
||||||
|
Realtime struct {
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
Humidity float64 `json:"humidity"`
|
||||||
|
Pressure float64 `json:"pressure"`
|
||||||
|
Wind struct {
|
||||||
|
Speed float64 `json:"speed"`
|
||||||
|
Direction float64 `json:"direction"`
|
||||||
|
} `json:"wind"`
|
||||||
|
} `json:"realtime"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchCaiyunRealtime fetches 10m wind plus T/RH/P for given lon,lat.
|
||||||
|
// Returns: speed(m/s), dir_from(deg), tempC, humidity(0-1), pressurePa
|
||||||
|
func FetchCaiyunRealtime(lon, lat float64) (float64, float64, float64, float64, float64, error) {
|
||||||
|
url := fmt.Sprintf("https://api.caiyunapp.com/v2.6/%s/%.6f,%.6f/realtime?unit=metric", caiyunToken, lon, lat)
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
cli := &http.Client{Timeout: 8 * time.Second}
|
||||||
|
resp, err := cli.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return 0, 0, 0, 0, 0, fmt.Errorf("caiyun http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var rr caiyunRealtimeResp
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&rr); err != nil {
|
||||||
|
return 0, 0, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
if rr.Status != "ok" {
|
||||||
|
return 0, 0, 0, 0, 0, fmt.Errorf("caiyun status %s", rr.Status)
|
||||||
|
}
|
||||||
|
rt := rr.Result.Realtime
|
||||||
|
return rt.Wind.Speed, rt.Wind.Direction, rt.Temperature, rt.Humidity, rt.Pressure, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compatible wrapper (wind only)
|
||||||
|
func FetchCaiyunWind(lon, lat float64) (float64, float64, error) {
|
||||||
|
s, d, _, _, _, err := FetchCaiyunRealtime(lon, lat)
|
||||||
|
return s, d, err
|
||||||
|
}
|
||||||
103
internal/radarfetch/clusterpng.go
Normal file
103
internal/radarfetch/clusterpng.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package radarfetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AttachClusterPNGs renders a tiny PNG for each cluster by flood-filling
|
||||||
|
// from its centroid on the thresholded mask, cropping to the cluster bbox.
|
||||||
|
// It writes files to outDir/clusters/cluster-<id>.png and returns updated clusters
|
||||||
|
// with PNG field filled.
|
||||||
|
func AttachClusterPNGs(grid [][]*float64, thr float64, clusters []Cluster, outDir string) ([]Cluster, error) {
|
||||||
|
const W, H = 256, 256
|
||||||
|
if len(grid) != H || (len(grid) > 0 && len(grid[0]) != W) {
|
||||||
|
return clusters, nil
|
||||||
|
}
|
||||||
|
// precompute threshold mask
|
||||||
|
mask := make([][]bool, H)
|
||||||
|
for r := 0; r < H; r++ {
|
||||||
|
mask[r] = make([]bool, W)
|
||||||
|
for c := 0; c < W; c++ {
|
||||||
|
if grid[r][c] == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if *grid[r][c] >= thr {
|
||||||
|
mask[r][c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outDir = filepath.Join(outDir, "clusters")
|
||||||
|
_ = os.MkdirAll(outDir, 0o755)
|
||||||
|
for i := range clusters {
|
||||||
|
cl := &clusters[i]
|
||||||
|
// BFS from (Row,Col) within mask to reconstruct membership
|
||||||
|
r0, c0 := cl.Row, cl.Col
|
||||||
|
if r0 < 0 || r0 >= H || c0 < 0 || c0 >= W || !mask[r0][c0] {
|
||||||
|
// skip if centroid not on mask
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
minR, minC := cl.MinRow, cl.MinCol
|
||||||
|
maxR, maxC := cl.MaxRow, cl.MaxCol
|
||||||
|
w := maxC - minC + 1
|
||||||
|
h := maxR - minR + 1
|
||||||
|
if w <= 0 || h <= 0 || w > W || h > H {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
// init transparent
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
img.SetRGBA(x, y, color.RGBA{0, 0, 0, 0})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// flood fill within bbox
|
||||||
|
vis := make([][]bool, H)
|
||||||
|
for r := 0; r < H; r++ {
|
||||||
|
vis[r] = make([]bool, W)
|
||||||
|
}
|
||||||
|
stack := [][2]int{{r0, c0}}
|
||||||
|
vis[r0][c0] = true
|
||||||
|
dirs := [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}, {-1, -1}, {-1, 1}, {1, -1}, {1, 1}}
|
||||||
|
for len(stack) > 0 {
|
||||||
|
cur := stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
rr, cc := cur[0], cur[1]
|
||||||
|
if rr < minR || rr > maxR || cc < minC || cc > maxC {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// paint
|
||||||
|
dbz := grid[rr][cc]
|
||||||
|
if dbz != nil {
|
||||||
|
col := colorForDBZ(*dbz)
|
||||||
|
img.SetRGBA(cc-minC, rr-minR, col)
|
||||||
|
}
|
||||||
|
for _, d := range dirs {
|
||||||
|
nr, nc := rr+d[0], cc+d[1]
|
||||||
|
if nr < 0 || nr >= H || nc < 0 || nc >= W {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if vis[nr][nc] || !mask[nr][nc] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vis[nr][nc] = true
|
||||||
|
stack = append(stack, [2]int{nr, nc})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// write file
|
||||||
|
name := fmt.Sprintf("cluster-%d.png", cl.ID)
|
||||||
|
p := filepath.Join(outDir, name)
|
||||||
|
f, err := os.Create(p)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = png.Encode(f, img)
|
||||||
|
_ = f.Close()
|
||||||
|
cl.PNG = filepath.Join(filepath.Base(outDir), name)
|
||||||
|
}
|
||||||
|
return clusters, nil
|
||||||
|
}
|
||||||
37
internal/radarfetch/fetch.go
Normal file
37
internal/radarfetch/fetch.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package radarfetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultUA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
func GetWithUA(url string, ua string, timeout time.Duration, extraHeaders map[string]string) ([]byte, error) {
|
||||||
|
c := &http.Client{Timeout: timeout}
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ua == "" {
|
||||||
|
ua = DefaultUA
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", ua)
|
||||||
|
for k, v := range extraHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||||
|
return nil, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
242
internal/radarfetch/job.go
Normal file
242
internal/radarfetch/job.go
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
package radarfetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartJob launches a background ticker to download NMC images and CMA bin using Huanan time.
|
||||||
|
type Options struct {
|
||||||
|
OutRoot string
|
||||||
|
TZOffset int
|
||||||
|
Interval time.Duration
|
||||||
|
NMCChinaURL string
|
||||||
|
NMCHuananURL string
|
||||||
|
NMCNanningURL string
|
||||||
|
CMABase string
|
||||||
|
Z, Y, X int
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunOnce executes a single download-render-save cycle.
|
||||||
|
func RunOnce(opts Options) error {
|
||||||
|
if opts.OutRoot == "" {
|
||||||
|
opts.OutRoot = "./radar_data"
|
||||||
|
}
|
||||||
|
if opts.TZOffset == 0 {
|
||||||
|
opts.TZOffset = 8
|
||||||
|
}
|
||||||
|
if opts.NMCChinaURL == "" {
|
||||||
|
opts.NMCChinaURL = "https://www.nmc.cn/publish/radar/chinaall.html"
|
||||||
|
}
|
||||||
|
if opts.NMCHuananURL == "" {
|
||||||
|
opts.NMCHuananURL = "https://www.nmc.cn/publish/radar/huanan.html"
|
||||||
|
}
|
||||||
|
if opts.NMCNanningURL == "" {
|
||||||
|
opts.NMCNanningURL = "https://www.nmc.cn/publish/radar/guang-xi/nan-ning.htm"
|
||||||
|
}
|
||||||
|
if opts.CMABase == "" {
|
||||||
|
opts.CMABase = "https://image.data.cma.cn"
|
||||||
|
}
|
||||||
|
if opts.Z == 0 && opts.Y == 0 && opts.X == 0 {
|
||||||
|
opts.Z, opts.Y, opts.X = 7, 40, 102
|
||||||
|
}
|
||||||
|
fmt.Printf("[radar] start run: out=%s z/y/x=%d/%d/%d tz=%d\n", opts.OutRoot, opts.Z, opts.Y, opts.X, opts.TZOffset)
|
||||||
|
err := runDownload(opts.OutRoot, opts.TZOffset, opts.NMCChinaURL, opts.NMCHuananURL, opts.NMCNanningURL, opts.CMABase, opts.Z, opts.Y, opts.X)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[radar] run failed: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("[radar] run done")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the periodic downloader (blocking ticker loop).
|
||||||
|
func Run(opts Options) {
|
||||||
|
if opts.OutRoot == "" {
|
||||||
|
opts.OutRoot = "./radar_data"
|
||||||
|
}
|
||||||
|
if opts.TZOffset == 0 {
|
||||||
|
opts.TZOffset = 8
|
||||||
|
}
|
||||||
|
if opts.Interval <= 0 {
|
||||||
|
opts.Interval = 10 * time.Minute
|
||||||
|
}
|
||||||
|
if opts.NMCChinaURL == "" {
|
||||||
|
opts.NMCChinaURL = "https://www.nmc.cn/publish/radar/chinaall.html"
|
||||||
|
}
|
||||||
|
if opts.NMCHuananURL == "" {
|
||||||
|
opts.NMCHuananURL = "https://www.nmc.cn/publish/radar/huanan.html"
|
||||||
|
}
|
||||||
|
if opts.NMCNanningURL == "" {
|
||||||
|
opts.NMCNanningURL = "https://www.nmc.cn/publish/radar/guang-xi/nan-ning.htm"
|
||||||
|
}
|
||||||
|
if opts.CMABase == "" {
|
||||||
|
opts.CMABase = "https://image.data.cma.cn"
|
||||||
|
}
|
||||||
|
if opts.Z == 0 && opts.Y == 0 && opts.X == 0 {
|
||||||
|
opts.Z, opts.Y, opts.X = 7, 40, 102
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = RunOnce(opts)
|
||||||
|
t := time.NewTicker(opts.Interval)
|
||||||
|
for range t.C {
|
||||||
|
_ = RunOnce(opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDownload(outRoot string, tzOffset int, chinaURL, huananURL, nanningURL, cmaBase string, z, y, x int) error {
|
||||||
|
// 1) Fetch NMC pages and parse image/time (time from Huanan)
|
||||||
|
fmt.Println("[radar] fetch NMC China page ...")
|
||||||
|
chinaHTML, err := GetWithUA(chinaURL, DefaultUA, 15*time.Second, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch NMC China: %w", err)
|
||||||
|
}
|
||||||
|
chinaImg, _, ok := ExtractFirstImageAndTime(chinaHTML)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("parse China page: data-img not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("[radar] fetch NMC Huanan page ...")
|
||||||
|
huananHTML, err := GetWithUA(huananURL, DefaultUA, 15*time.Second, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch NMC Huanan: %w", err)
|
||||||
|
}
|
||||||
|
huananImg, huananTime, ok := ExtractFirstImageAndTime(huananHTML)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("parse Huanan page: data-img not found")
|
||||||
|
}
|
||||||
|
date, hour, minute, tsLocal := ParseNmcTime(huananTime, tzOffset)
|
||||||
|
|
||||||
|
fmt.Println("[radar] fetch NMC Nanning page ...")
|
||||||
|
nanningHTML, err := GetWithUA(nanningURL, DefaultUA, 15*time.Second, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch NMC Nanning: %w", err)
|
||||||
|
}
|
||||||
|
nanningImg, _, ok := ExtractFirstImageAndTime(nanningHTML)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("parse Nanning page: data-img not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare out directory
|
||||||
|
outDir := filepath.Join(outRoot, fmt.Sprintf("%04d%02d%02d", date/10000, (date/100)%100, date%100), fmt.Sprintf("%02d", hour), fmt.Sprintf("%02d", minute))
|
||||||
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("mkdir outDir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download three images (with Referer)
|
||||||
|
imgHeaders := map[string]string{
|
||||||
|
"Referer": "https://www.nmc.cn/",
|
||||||
|
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
|
}
|
||||||
|
fmt.Println("[radar] download China/Huanan/Nanning images ...")
|
||||||
|
if b, err := GetWithUA(chinaImg, DefaultUA, 20*time.Second, imgHeaders); err == nil {
|
||||||
|
_ = os.WriteFile(filepath.Join(outDir, "nmc_chinaall.png"), b, 0o644)
|
||||||
|
}
|
||||||
|
if b, err := GetWithUA(huananImg, DefaultUA, 20*time.Second, imgHeaders); err == nil {
|
||||||
|
_ = os.WriteFile(filepath.Join(outDir, "nmc_huanan.png"), b, 0o644)
|
||||||
|
}
|
||||||
|
if b, err := GetWithUA(nanningImg, DefaultUA, 20*time.Second, imgHeaders); err == nil {
|
||||||
|
_ = os.WriteFile(filepath.Join(outDir, "nmc_nanning.png"), b, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Fetch CMA BIN with Huanan time
|
||||||
|
binURL := BuildCMAURL(cmaBase, date, hour, minute, z, y, x)
|
||||||
|
binHeaders := map[string]string{
|
||||||
|
"Referer": "https://data.cma.cn/",
|
||||||
|
"Origin": "https://data.cma.cn",
|
||||||
|
"User-Agent": DefaultUA,
|
||||||
|
"Accept": "*/*",
|
||||||
|
}
|
||||||
|
fmt.Printf("[radar] download CMA bin z/y/x=%d/%d/%d ...\n", z, y, x)
|
||||||
|
binBytes, err := GetWithUA(binURL, DefaultUA, 30*time.Second, binHeaders)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch BIN: %w", err)
|
||||||
|
}
|
||||||
|
binPath := filepath.Join(outDir, fmt.Sprintf("%d-%d-%d.bin", z, y, x))
|
||||||
|
if err := os.WriteFile(binPath, binBytes, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("save BIN: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render BIN -> PNG
|
||||||
|
cmaPNG := filepath.Join(outDir, fmt.Sprintf("cma_%d-%d-%d.png", z, y, x))
|
||||||
|
fmt.Println("[radar] render bin -> png ...")
|
||||||
|
if err := RenderBinToPNG(binPath, cmaPNG, true); err != nil {
|
||||||
|
return fmt.Errorf("render PNG: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode grid and detect clusters (>=40 dBZ)
|
||||||
|
fmt.Println("[radar] decode grid & detect clusters ...")
|
||||||
|
grid := make([][]*float64, 256)
|
||||||
|
{
|
||||||
|
const w, h = 256, 256
|
||||||
|
if len(binBytes) == w*h*2 {
|
||||||
|
for row := 0; row < h; row++ {
|
||||||
|
line := make([]*float64, w)
|
||||||
|
for col := 0; col < w; col++ {
|
||||||
|
off := (row*w + col) * 2
|
||||||
|
u := uint16(binBytes[off])<<8 | uint16(binBytes[off+1])
|
||||||
|
vv := int16(u)
|
||||||
|
if vv == 32767 || vv < 0 {
|
||||||
|
line[col] = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dbz := float64(vv) / 10.0
|
||||||
|
line[col] = &dbz
|
||||||
|
}
|
||||||
|
grid[row] = line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Write metadata and update latest
|
||||||
|
w, s, e, n, res := Bounds4326(z, y, x)
|
||||||
|
meta := Metadata{
|
||||||
|
TimestampLocal: tsLocal,
|
||||||
|
Date: date,
|
||||||
|
Hour: hour,
|
||||||
|
Minute: minute,
|
||||||
|
Z: z,
|
||||||
|
Y: y,
|
||||||
|
X: x,
|
||||||
|
Bounds: Bounds{West: w, South: s, East: e, North: n},
|
||||||
|
ResDeg: res,
|
||||||
|
Sources: Sources{NmcHTML: huananURL, NmcImg: huananImg, CmaBin: binURL},
|
||||||
|
Files: Files{HTML: "", PNG: filepath.Join(outDir, "nmc_huanan.png"), BIN: binPath, Metadata: filepath.Join(outDir, "metadata.json"), CMAPNG: cmaPNG},
|
||||||
|
Sizes: Sizes{PNG: fileSize(filepath.Join(outDir, "nmc_huanan.png")), BIN: int64(len(binBytes))},
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
// Attach clusters if grid decoded
|
||||||
|
if grid[0] != nil {
|
||||||
|
meta.Clusters = SegmentClusters(grid, Bounds{West: w, South: s, East: e, North: n}, res, 40.0)
|
||||||
|
// Render small PNGs per cluster
|
||||||
|
if len(meta.Clusters) > 0 {
|
||||||
|
if updated, err2 := AttachClusterPNGs(grid, 40.0, meta.Clusters, outDir); err2 == nil {
|
||||||
|
meta.Clusters = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meta.AnalysisNote = "clusters>=40dBZ; samples=center+4rays (N/S/E/W)"
|
||||||
|
// Build wind query plan with defaults
|
||||||
|
meta.QueryParams = QueryParams{MinAreaPx: 9, StrongDBZOverride: 50, MaxSamplesPerCluster: 5, MaxCandidatesTotal: 25}
|
||||||
|
cl2, cands := PlanWindQuery(meta.Clusters, meta.QueryParams)
|
||||||
|
meta.Clusters = cl2
|
||||||
|
meta.QueryCandidates = cands
|
||||||
|
}
|
||||||
|
if err := WriteMetadata(filepath.Join(outDir, "metadata.json"), &meta); err != nil {
|
||||||
|
return fmt.Errorf("write metadata: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("[radar] update latest snapshot ...")
|
||||||
|
if err := UpdateLatest(outRoot, outDir, &meta); err != nil {
|
||||||
|
return fmt.Errorf("update latest: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSize(p string) int64 {
|
||||||
|
fi, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return fi.Size()
|
||||||
|
}
|
||||||
51
internal/radarfetch/parse.go
Normal file
51
internal/radarfetch/parse.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package radarfetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtractFirstImageAndTime tries to extract data-img and data-time from NMC HTML.
|
||||||
|
// It first searches for an element with class "time" that carries data-img/time;
|
||||||
|
// falls back to the first occurrence of data-img / data-time attributes in the HTML.
|
||||||
|
func ExtractFirstImageAndTime(html []byte) (img string, timeStr string, ok bool) {
|
||||||
|
// naive scan for data-img and data-time on the same segment first
|
||||||
|
// Search for class="time" anchor to bias to the right element
|
||||||
|
idx := bytes.Index(html, []byte("class=\"time\""))
|
||||||
|
start := 0
|
||||||
|
if idx >= 0 {
|
||||||
|
// back up a bit to include attributes on same tag
|
||||||
|
if idx > 200 {
|
||||||
|
start = idx - 200
|
||||||
|
} else {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seg := html[start:]
|
||||||
|
img = findAttr(seg, "data-img")
|
||||||
|
timeStr = findAttr(seg, "data-time")
|
||||||
|
if img != "" {
|
||||||
|
return img, timeStr, true
|
||||||
|
}
|
||||||
|
// fallback: first data-img anywhere
|
||||||
|
img = findAttr(html, "data-img")
|
||||||
|
timeStr = findAttr(html, "data-time")
|
||||||
|
if img != "" {
|
||||||
|
return img, timeStr, true
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func findAttr(b []byte, key string) string {
|
||||||
|
// look for key="..."
|
||||||
|
pat := []byte(key + "=\"")
|
||||||
|
i := bytes.Index(b, pat)
|
||||||
|
if i < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
i += len(pat)
|
||||||
|
j := bytes.IndexByte(b[i:], '"')
|
||||||
|
if j < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b[i : i+j])
|
||||||
|
}
|
||||||
65
internal/radarfetch/query.go
Normal file
65
internal/radarfetch/query.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package radarfetch
|
||||||
|
|
||||||
|
// PlanWindQuery marks clusters as eligible or not based on params and
|
||||||
|
// returns a flattened list of sample points for eligible clusters.
|
||||||
|
func PlanWindQuery(clusters []Cluster, params QueryParams) ([]Cluster, []QueryCandidate) {
|
||||||
|
if params.MinAreaPx <= 0 {
|
||||||
|
params.MinAreaPx = 9
|
||||||
|
}
|
||||||
|
if params.StrongDBZOverride <= 0 {
|
||||||
|
params.StrongDBZOverride = 50
|
||||||
|
}
|
||||||
|
if params.MaxSamplesPerCluster <= 0 {
|
||||||
|
params.MaxSamplesPerCluster = 5
|
||||||
|
}
|
||||||
|
if params.MaxCandidatesTotal <= 0 {
|
||||||
|
params.MaxCandidatesTotal = 25
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]QueryCandidate, 0, len(clusters)*2)
|
||||||
|
for i := range clusters {
|
||||||
|
cl := &clusters[i]
|
||||||
|
eligible := cl.AreaPx >= params.MinAreaPx || cl.MaxDBZ >= params.StrongDBZOverride
|
||||||
|
if !eligible {
|
||||||
|
cl.EligibleForQuery = false
|
||||||
|
cl.SkipReason = "too_small_and_weak"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cl.EligibleForQuery = true
|
||||||
|
cl.SkipReason = ""
|
||||||
|
// choose up to MaxSamplesPerCluster from samples (prefer center first)
|
||||||
|
if len(cl.Samples) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// order: center first, then others as-is
|
||||||
|
picked := 0
|
||||||
|
// ensure center first if exists
|
||||||
|
for _, s := range cl.Samples {
|
||||||
|
if s.Role == "center" {
|
||||||
|
out = append(out, QueryCandidate{ClusterID: cl.ID, Role: s.Role, Lon: s.Lon, Lat: s.Lat})
|
||||||
|
picked++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range cl.Samples {
|
||||||
|
if picked >= params.MaxSamplesPerCluster {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if s.Role == "center" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, QueryCandidate{ClusterID: cl.ID, Role: s.Role, Lon: s.Lon, Lat: s.Lat})
|
||||||
|
picked++
|
||||||
|
}
|
||||||
|
if picked == 0 {
|
||||||
|
// fallback: take first
|
||||||
|
s := cl.Samples[0]
|
||||||
|
out = append(out, QueryCandidate{ClusterID: cl.ID, Role: s.Role, Lon: s.Lon, Lat: s.Lat})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// cap total
|
||||||
|
if len(out) > params.MaxCandidatesTotal {
|
||||||
|
out = out[:params.MaxCandidatesTotal]
|
||||||
|
}
|
||||||
|
return clusters, out
|
||||||
|
}
|
||||||
33
internal/radarfetch/radar.go
Normal file
33
internal/radarfetch/radar.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package radarfetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildCMAURL(base string, date int, hour int, minute int, z int, y int, x int) string {
|
||||||
|
yyyy := date / 10000
|
||||||
|
mm := (date / 100) % 100
|
||||||
|
dd := date % 100
|
||||||
|
return fmt.Sprintf("%s/tiles/China/RADAR_L3_MST_CREF_GISJPG_Tiles_CR/%04d%02d%02d/%02d/%02d/%d/%d/%d.bin",
|
||||||
|
trimSlash(base), yyyy, mm, dd, hour, minute, z, y, x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Bounds4326(z, y, x int) (west, south, east, north, resDeg float64) {
|
||||||
|
step := 360.0 / math.Ldexp(1.0, z)
|
||||||
|
west = -180.0 + float64(x)*step
|
||||||
|
east = west + step
|
||||||
|
south = -90.0 + float64(y)*step
|
||||||
|
north = south + step
|
||||||
|
resDeg = step / 256.0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimSlash(s string) string {
|
||||||
|
n := len(s)
|
||||||
|
for n > 0 && s[n-1] == '/' {
|
||||||
|
s = s[:n-1]
|
||||||
|
n--
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
97
internal/radarfetch/render.go
Normal file
97
internal/radarfetch/render.go
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
package radarfetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var colors15 = []string{
|
||||||
|
"#0000F6", "#01A0F6", "#00ECEC", "#01FF00", "#00C800",
|
||||||
|
"#019000", "#FFFF00", "#E7C000", "#FF9000", "#FF0000",
|
||||||
|
"#D60000", "#C00000", "#FF00F0", "#780084", "#AD90F0",
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexToRGBA(s string, a uint8) color.RGBA {
|
||||||
|
if len(s) >= 7 && s[0] == '#' {
|
||||||
|
r := xtoi(s[1:3])
|
||||||
|
g := xtoi(s[3:5])
|
||||||
|
b := xtoi(s[5:7])
|
||||||
|
return color.RGBA{uint8(r), uint8(g), uint8(b), a}
|
||||||
|
}
|
||||||
|
return color.RGBA{0, 0, 0, 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func xtoi(h string) int {
|
||||||
|
v := 0
|
||||||
|
for i := 0; i < len(h); i++ {
|
||||||
|
c := h[i]
|
||||||
|
v <<= 4
|
||||||
|
switch {
|
||||||
|
case c >= '0' && c <= '9':
|
||||||
|
v |= int(c - '0')
|
||||||
|
case c >= 'a' && c <= 'f':
|
||||||
|
v |= int(c-'a') + 10
|
||||||
|
case c >= 'A' && c <= 'F':
|
||||||
|
v |= int(c-'A') + 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorForDBZ(dbz float64) color.RGBA {
|
||||||
|
if dbz < 0 {
|
||||||
|
return color.RGBA{0, 0, 0, 0}
|
||||||
|
}
|
||||||
|
idx := int(dbz / 5.0)
|
||||||
|
if idx < 0 {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
if idx >= len(colors15) {
|
||||||
|
idx = len(colors15) - 1
|
||||||
|
}
|
||||||
|
return hexToRGBA(colors15[idx], 255)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderBinToPNG renders 256x256 BE int16 .bin into a PNG using CMA-style colors.
|
||||||
|
func RenderBinToPNG(srcPath, dstPath string, flipY bool) error {
|
||||||
|
b, err := os.ReadFile(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
const w, h = 256, 256
|
||||||
|
if len(b) != w*h*2 {
|
||||||
|
return ErrSize
|
||||||
|
}
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
for row := 0; row < h; row++ {
|
||||||
|
outRow := row
|
||||||
|
if flipY {
|
||||||
|
outRow = h - 1 - row
|
||||||
|
}
|
||||||
|
for col := 0; col < w; col++ {
|
||||||
|
off := (row*w + col) * 2
|
||||||
|
u := uint16(b[off])<<8 | uint16(b[off+1])
|
||||||
|
v := int16(u)
|
||||||
|
if v == 32767 || v < 0 {
|
||||||
|
img.SetRGBA(col, outRow, color.RGBA{0, 0, 0, 0})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dbz := float64(v) / 10.0
|
||||||
|
img.SetRGBA(col, outRow, colorForDBZ(dbz))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f, err := os.Create(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return png.Encode(f, img)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrSize = errSize{}
|
||||||
|
|
||||||
|
type errSize struct{}
|
||||||
|
|
||||||
|
func (errSize) Error() string { return "unexpected .bin size (expected 131072 bytes)" }
|
||||||
163
internal/radarfetch/segment.go
Normal file
163
internal/radarfetch/segment.go
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
package radarfetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SegmentClusters finds 8-connected regions where dBZ >= thr (e.g., 40),
|
||||||
|
// computes stats and recommended sampling points per cluster.
|
||||||
|
// Input grid: 256x256, invalid as NaN; bounds/res used to compute lon/lat.
|
||||||
|
func SegmentClusters(grid [][]*float64, bounds Bounds, resDeg float64, thr float64) []Cluster {
|
||||||
|
const W, H = 256, 256
|
||||||
|
if len(grid) != H || (len(grid) > 0 && len(grid[0]) != W) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Mask of eligible pixels
|
||||||
|
mask := make([][]bool, H)
|
||||||
|
for r := 0; r < H; r++ {
|
||||||
|
mask[r] = make([]bool, W)
|
||||||
|
for c := 0; c < W; c++ {
|
||||||
|
if grid[r][c] == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := *grid[r][c]
|
||||||
|
if v >= thr {
|
||||||
|
mask[r][c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visited flags
|
||||||
|
vis := make([][]bool, H)
|
||||||
|
for r := 0; r < H; r++ {
|
||||||
|
vis[r] = make([]bool, W)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8-neighborhood
|
||||||
|
nbr := [8][2]int{{-1, -1}, {-1, 0}, {-1, 1}, {0, -1}, {0, 1}, {1, -1}, {1, 0}, {1, 1}}
|
||||||
|
|
||||||
|
var clusters []Cluster
|
||||||
|
clusterID := 0
|
||||||
|
for r := 0; r < H; r++ {
|
||||||
|
for c := 0; c < W; c++ {
|
||||||
|
if !mask[r][c] || vis[r][c] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// BFS/DFS stack
|
||||||
|
stack := [][2]int{{r, c}}
|
||||||
|
vis[r][c] = true
|
||||||
|
// stats
|
||||||
|
area := 0
|
||||||
|
sumW := 0.0
|
||||||
|
sumWR := 0.0
|
||||||
|
sumWC := 0.0
|
||||||
|
maxDBZ := -math.MaxFloat64
|
||||||
|
sumDBZ := 0.0
|
||||||
|
minR, minC := r, c
|
||||||
|
maxR, maxC := r, c
|
||||||
|
pixels := make([][2]int, 0, 512)
|
||||||
|
for len(stack) > 0 {
|
||||||
|
cur := stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
rr, cc := cur[0], cur[1]
|
||||||
|
area++
|
||||||
|
dbz := *grid[rr][cc]
|
||||||
|
w := dbz // dBZ-weighted centroid
|
||||||
|
sumW += w
|
||||||
|
sumWR += float64(rr) * w
|
||||||
|
sumWC += float64(cc) * w
|
||||||
|
if dbz > maxDBZ {
|
||||||
|
maxDBZ = dbz
|
||||||
|
}
|
||||||
|
sumDBZ += dbz
|
||||||
|
if rr < minR {
|
||||||
|
minR = rr
|
||||||
|
}
|
||||||
|
if cc < minC {
|
||||||
|
minC = cc
|
||||||
|
}
|
||||||
|
if rr > maxR {
|
||||||
|
maxR = rr
|
||||||
|
}
|
||||||
|
if cc > maxC {
|
||||||
|
maxC = cc
|
||||||
|
}
|
||||||
|
pixels = append(pixels, [2]int{rr, cc})
|
||||||
|
for _, d := range nbr {
|
||||||
|
nr, nc := rr+d[0], cc+d[1]
|
||||||
|
if nr < 0 || nr >= H || nc < 0 || nc >= W {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if vis[nr][nc] || !mask[nr][nc] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vis[nr][nc] = true
|
||||||
|
stack = append(stack, [2]int{nr, nc})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if area == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// centroid (row/col)
|
||||||
|
cr, cc := 0.0, 0.0
|
||||||
|
if sumW > 0 {
|
||||||
|
cr = sumWR / sumW
|
||||||
|
cc = sumWC / sumW
|
||||||
|
} else {
|
||||||
|
// fallback to geometric center
|
||||||
|
cr = float64(minR+maxR) / 2.0
|
||||||
|
cc = float64(minC+maxC) / 2.0
|
||||||
|
}
|
||||||
|
// Convert centroid to lon/lat (pixel center)
|
||||||
|
clon := bounds.West + (cc+0.5)*resDeg
|
||||||
|
clat := bounds.South + (cr+0.5)*resDeg
|
||||||
|
|
||||||
|
// Sample points: center + four rays (N,S,E,W) until boundary
|
||||||
|
samples := make([]Sample, 0, 5)
|
||||||
|
samples = append(samples, Sample{Row: int(math.Round(cr)), Col: int(math.Round(cc)), Lon: clon, Lat: clat, Role: "center"})
|
||||||
|
// helper to step ray and clamp to last in-mask pixel
|
||||||
|
stepRay := func(dr, dc int, role string) {
|
||||||
|
rr := int(math.Round(cr))
|
||||||
|
cc2 := int(math.Round(cc))
|
||||||
|
lastR, lastC := rr, cc2
|
||||||
|
for {
|
||||||
|
rr += dr
|
||||||
|
cc2 += dc
|
||||||
|
if rr < 0 || rr >= H || cc2 < 0 || cc2 >= W {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !mask[rr][cc2] {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lastR, lastC = rr, cc2
|
||||||
|
}
|
||||||
|
lon := bounds.West + (float64(lastC)+0.5)*resDeg
|
||||||
|
lat := bounds.South + (float64(lastR)+0.5)*resDeg
|
||||||
|
if lastR != samples[0].Row || lastC != samples[0].Col {
|
||||||
|
samples = append(samples, Sample{Row: lastR, Col: lastC, Lon: lon, Lat: lat, Role: role})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stepRay(-1, 0, "ray_n")
|
||||||
|
stepRay(1, 0, "ray_s")
|
||||||
|
stepRay(0, 1, "ray_e")
|
||||||
|
stepRay(0, -1, "ray_w")
|
||||||
|
|
||||||
|
avgDBZ := sumDBZ / float64(area)
|
||||||
|
cluster := Cluster{
|
||||||
|
ID: clusterID,
|
||||||
|
AreaPx: area,
|
||||||
|
MaxDBZ: maxDBZ,
|
||||||
|
AvgDBZ: avgDBZ,
|
||||||
|
Row: int(math.Round(cr)),
|
||||||
|
Col: int(math.Round(cc)),
|
||||||
|
Lon: clon,
|
||||||
|
Lat: clat,
|
||||||
|
MinRow: minR, MinCol: minC, MaxRow: maxR, MaxCol: maxC,
|
||||||
|
Samples: samples,
|
||||||
|
}
|
||||||
|
clusters = append(clusters, cluster)
|
||||||
|
clusterID++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clusters
|
||||||
|
}
|
||||||
159
internal/radarfetch/store.go
Normal file
159
internal/radarfetch/store.go
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
package radarfetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Bounds struct {
|
||||||
|
West float64 `json:"west"`
|
||||||
|
South float64 `json:"south"`
|
||||||
|
East float64 `json:"east"`
|
||||||
|
North float64 `json:"north"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sources struct {
|
||||||
|
NmcHTML string `json:"nmc_html"`
|
||||||
|
NmcImg string `json:"nmc_img"`
|
||||||
|
CmaBin string `json:"cma_bin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Files struct {
|
||||||
|
HTML string `json:"html"`
|
||||||
|
PNG string `json:"png"`
|
||||||
|
BIN string `json:"bin"`
|
||||||
|
Metadata string `json:"metadata"`
|
||||||
|
CMAPNG string `json:"cma_png"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sizes struct {
|
||||||
|
PNG int64 `json:"png"`
|
||||||
|
BIN int64 `json:"bin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Metadata struct {
|
||||||
|
TimestampLocal string `json:"timestamp_local"`
|
||||||
|
Date int `json:"date"`
|
||||||
|
Hour int `json:"hour"`
|
||||||
|
Minute int `json:"minute"`
|
||||||
|
Z int `json:"z"`
|
||||||
|
Y int `json:"y"`
|
||||||
|
X int `json:"x"`
|
||||||
|
Bounds Bounds `json:"bounds"`
|
||||||
|
ResDeg float64 `json:"res_deg"`
|
||||||
|
Sources Sources `json:"sources"`
|
||||||
|
Files Files `json:"files"`
|
||||||
|
Sizes Sizes `json:"sizes"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
// Cloud clusters detected from single-frame CREF (>=40 dBZ)
|
||||||
|
// Optional; may be empty when detection fails.
|
||||||
|
Clusters []Cluster `json:"clusters,omitempty"`
|
||||||
|
// Optional notes about sampling strategy or thresholds used
|
||||||
|
AnalysisNote string `json:"analysis_note,omitempty"`
|
||||||
|
// Wind query planning parameters and candidates
|
||||||
|
QueryParams QueryParams `json:"query_params,omitempty"`
|
||||||
|
QueryCandidates []QueryCandidate `json:"query_candidates,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cluster represents a connected echo region above threshold.
|
||||||
|
type Cluster struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
AreaPx int `json:"area_px"`
|
||||||
|
MaxDBZ float64 `json:"max_dbz"`
|
||||||
|
AvgDBZ float64 `json:"avg_dbz"`
|
||||||
|
// Pixel-space centroid (row, col) using dBZ-weighted center
|
||||||
|
Row int `json:"row"`
|
||||||
|
Col int `json:"col"`
|
||||||
|
// Centroid lon/lat of pixel center
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
// Bounding box in pixel coords (inclusive)
|
||||||
|
MinRow int `json:"min_row"`
|
||||||
|
MinCol int `json:"min_col"`
|
||||||
|
MaxRow int `json:"max_row"`
|
||||||
|
MaxCol int `json:"max_col"`
|
||||||
|
// Recommended sample points for downstream wind queries
|
||||||
|
Samples []Sample `json:"samples"`
|
||||||
|
// Optional path to a small PNG rendering of this cluster (copied to latest)
|
||||||
|
PNG string `json:"png,omitempty"`
|
||||||
|
// Eligibility for downstream wind query
|
||||||
|
EligibleForQuery bool `json:"eligible_for_query,omitempty"`
|
||||||
|
SkipReason string `json:"skip_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sample struct {
|
||||||
|
Row int `json:"row"`
|
||||||
|
Col int `json:"col"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
// role: center | ray_n | ray_s | ray_e | ray_w
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameters controlling wind query candidate selection.
|
||||||
|
type QueryParams struct {
|
||||||
|
MinAreaPx int `json:"min_area_px"`
|
||||||
|
StrongDBZOverride float64 `json:"strong_dbz_override"`
|
||||||
|
MaxSamplesPerCluster int `json:"max_samples_per_cluster"`
|
||||||
|
MaxCandidatesTotal int `json:"max_candidates_total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single candidate point to query external wind API.
|
||||||
|
type QueryCandidate struct {
|
||||||
|
ClusterID int `json:"cluster_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteMetadata(path string, m *Metadata) error {
|
||||||
|
b, err := json.MarshalIndent(m, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, b, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateLatest(root string, curDir string, m *Metadata) error {
|
||||||
|
latest := filepath.Join(root, "latest")
|
||||||
|
if err := os.MkdirAll(latest, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Write latest.json
|
||||||
|
b, _ := json.MarshalIndent(struct {
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
Meta *Metadata `json:"meta"`
|
||||||
|
}{Dir: curDir, Meta: m}, "", " ")
|
||||||
|
_ = os.WriteFile(filepath.Join(latest, "latest.json"), b, 0o644)
|
||||||
|
|
||||||
|
copyFile := func(name string) {
|
||||||
|
dst := filepath.Join(latest, name)
|
||||||
|
src := filepath.Join(curDir, name)
|
||||||
|
data, e2 := os.ReadFile(src)
|
||||||
|
if e2 == nil {
|
||||||
|
// ensure parent dir exists for nested paths like "clusters/..."
|
||||||
|
_ = os.MkdirAll(filepath.Dir(dst), 0o755)
|
||||||
|
_ = os.WriteFile(dst, data, 0o644)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copyFile("nmc_chinaall.png")
|
||||||
|
copyFile("nmc_huanan.png")
|
||||||
|
copyFile("nmc_nanning.png")
|
||||||
|
copyFile("metadata.json")
|
||||||
|
copyFile(fmt.Sprintf("%d-%d-%d.bin", m.Z, m.Y, m.X))
|
||||||
|
if m.Files.CMAPNG != "" {
|
||||||
|
copyFile(filepath.Base(m.Files.CMAPNG))
|
||||||
|
}
|
||||||
|
// copy cluster PNGs if present
|
||||||
|
if len(m.Clusters) > 0 {
|
||||||
|
for _, cl := range m.Clusters {
|
||||||
|
if cl.PNG == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copyFile(cl.PNG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
37
internal/radarfetch/timeutil.go
Normal file
37
internal/radarfetch/timeutil.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package radarfetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseNmcTime parses like "MM/DD HH:MM" with a local tz offset (hours).
|
||||||
|
func ParseNmcTime(s string, tzOffset int) (date int, hour int, minute int, tsLocal string) {
|
||||||
|
parts := strings.Fields(s)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
md := strings.Split(parts[0], "/")
|
||||||
|
hm := strings.Split(parts[1], ":")
|
||||||
|
if len(md) == 2 && len(hm) == 2 {
|
||||||
|
now := time.Now().UTC().Add(time.Duration(tzOffset) * time.Hour)
|
||||||
|
y := now.Year()
|
||||||
|
m, _ := strconv.Atoi(md[0])
|
||||||
|
d, _ := strconv.Atoi(md[1])
|
||||||
|
h, _ := strconv.Atoi(hm[0])
|
||||||
|
mm, _ := strconv.Atoi(hm[1])
|
||||||
|
loc := time.FixedZone("LOCAL", tzOffset*3600)
|
||||||
|
t := time.Date(y, time.Month(m), d, h, mm, 0, 0, loc)
|
||||||
|
date = t.Year()*10000 + int(t.Month())*100 + t.Day()
|
||||||
|
hour = t.Hour()
|
||||||
|
minute = t.Minute()
|
||||||
|
tsLocal = t.Format("2006-01-02 15:04:05")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Add(time.Duration(tzOffset) * time.Hour)
|
||||||
|
date = now.Year()*10000 + int(now.Month())*100 + now.Day()
|
||||||
|
hour = now.Hour()
|
||||||
|
minute = now.Minute()
|
||||||
|
tsLocal = now.Format("2006-01-02 15:04:05")
|
||||||
|
return
|
||||||
|
}
|
||||||
@ -1,16 +1,21 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
"weatherstation/internal/config"
|
"weatherstation/internal/config"
|
||||||
"weatherstation/internal/database"
|
"weatherstation/internal/database"
|
||||||
|
rf "weatherstation/internal/radarfetch"
|
||||||
"weatherstation/pkg/types"
|
"weatherstation/pkg/types"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"math"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StartGinServer 启动Gin Web服务器
|
// StartGinServer 启动Gin Web服务器
|
||||||
@ -26,6 +31,8 @@ func StartGinServer() error {
|
|||||||
|
|
||||||
// 静态文件服务
|
// 静态文件服务
|
||||||
r.Static("/static", "./static")
|
r.Static("/static", "./static")
|
||||||
|
// 雷达数据静态目录(用于访问 latest 下的图片/二进制)
|
||||||
|
r.Static("/radar", "./radar_data")
|
||||||
|
|
||||||
// 路由设置
|
// 路由设置
|
||||||
r.GET("/", indexHandler)
|
r.GET("/", indexHandler)
|
||||||
@ -37,6 +44,9 @@ func StartGinServer() error {
|
|||||||
api.GET("/stations", getStationsHandler)
|
api.GET("/stations", getStationsHandler)
|
||||||
api.GET("/data", getDataHandler)
|
api.GET("/data", getDataHandler)
|
||||||
api.GET("/forecast", getForecastHandler)
|
api.GET("/forecast", getForecastHandler)
|
||||||
|
api.GET("/radar/latest", radarLatestHandler)
|
||||||
|
api.GET("/radar/latest/grid", radarLatestGridHandler)
|
||||||
|
api.GET("/radar/latest/wind", radarLatestWindHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置的Web端口
|
// 获取配置的Web端口
|
||||||
@ -45,6 +55,8 @@ func StartGinServer() error {
|
|||||||
port = 10003 // 默认端口
|
port = 10003 // 默认端口
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 备注:雷达抓取改为独立 CLI 触发,Web 服务不自动启动后台任务
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
fmt.Printf("Gin Web服务器启动,监听端口 %d...\n", port)
|
fmt.Printf("Gin Web服务器启动,监听端口 %d...\n", port)
|
||||||
return r.Run(fmt.Sprintf(":%d", port))
|
return r.Run(fmt.Sprintf(":%d", port))
|
||||||
@ -61,6 +73,8 @@ func indexHandler(c *gin.Context) {
|
|||||||
c.HTML(http.StatusOK, "index.html", data)
|
c.HTML(http.StatusOK, "index.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 备注:雷达站采用前端 Tab(hash)切换,无需单独路由
|
||||||
|
|
||||||
// systemStatusHandler 处理系统状态API请求
|
// systemStatusHandler 处理系统状态API请求
|
||||||
func systemStatusHandler(c *gin.Context) {
|
func systemStatusHandler(c *gin.Context) {
|
||||||
status := types.SystemStatus{
|
status := types.SystemStatus{
|
||||||
@ -204,3 +218,299 @@ func getForecastHandler(c *gin.Context) {
|
|||||||
log.Printf("查询到预报数据: %d 条", len(points))
|
log.Printf("查询到预报数据: %d 条", len(points))
|
||||||
c.JSON(http.StatusOK, points)
|
c.JSON(http.StatusOK, points)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// radarLatestHandler 返回最新一次雷达抓取的元数据与图片URL
|
||||||
|
func radarLatestHandler(c *gin.Context) {
|
||||||
|
// 读取 latest/metadata.json
|
||||||
|
latestRoot := "./radar_data/latest"
|
||||||
|
metaPath := latestRoot + "/metadata.json"
|
||||||
|
b, err := os.ReadFile(metaPath)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "未找到最新雷达元数据"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var meta map[string]any
|
||||||
|
if err := json.Unmarshal(b, &meta); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析元数据失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 构造图片URL(通过 /radar/latest/* 静态路径访问)
|
||||||
|
images := map[string]string{
|
||||||
|
"china": "/radar/latest/nmc_chinaall.png",
|
||||||
|
"huanan": "/radar/latest/nmc_huanan.png",
|
||||||
|
"nanning": "/radar/latest/nmc_nanning.png",
|
||||||
|
}
|
||||||
|
if files, ok := meta["files"].(map[string]any); ok {
|
||||||
|
if v, ok2 := files["cma_png"].(string); ok2 && v != "" {
|
||||||
|
_, name := path.Split(v)
|
||||||
|
images["cma"] = "/radar/latest/" + name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"meta": meta,
|
||||||
|
"images": images,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// radarLatestGridHandler 读取 latest 下的 z-y-x.bin 并返回 256x256 的 dBZ 二维数组(无效为 null)
|
||||||
|
func radarLatestGridHandler(c *gin.Context) {
|
||||||
|
latestRoot := "./radar_data/latest"
|
||||||
|
metaPath := latestRoot + "/metadata.json"
|
||||||
|
b, err := os.ReadFile(metaPath)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "未找到最新雷达元数据"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var meta map[string]any
|
||||||
|
if err := json.Unmarshal(b, &meta); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析元数据失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
z := intFromMeta(meta, "z")
|
||||||
|
y := intFromMeta(meta, "y")
|
||||||
|
x := intFromMeta(meta, "x")
|
||||||
|
binName := fmt.Sprintf("%d-%d-%d.bin", z, y, x)
|
||||||
|
binPath := path.Join(latestRoot, binName)
|
||||||
|
buf, err := os.ReadFile(binPath)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "未找到最新BIN文件"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const w, h = 256, 256
|
||||||
|
if len(buf) != w*h*2 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "BIN尺寸异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
grid := make([][]*float64, h)
|
||||||
|
for r := 0; r < h; r++ {
|
||||||
|
row := make([]*float64, w)
|
||||||
|
for c2 := 0; c2 < w; c2++ {
|
||||||
|
off := (r*w + c2) * 2
|
||||||
|
u := uint16(buf[off])<<8 | uint16(buf[off+1])
|
||||||
|
v := int16(u)
|
||||||
|
if v == 32767 || v < 0 {
|
||||||
|
row[c2] = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dbz := float64(v) / 10.0
|
||||||
|
row[c2] = &dbz
|
||||||
|
}
|
||||||
|
grid[r] = row
|
||||||
|
}
|
||||||
|
bounds := map[string]float64{"west": 0, "south": 0, "east": 0, "north": 0}
|
||||||
|
if v, ok := meta["bounds"].(map[string]any); ok {
|
||||||
|
if f, ok2 := v["west"].(float64); ok2 {
|
||||||
|
bounds["west"] = f
|
||||||
|
}
|
||||||
|
if f, ok2 := v["south"].(float64); ok2 {
|
||||||
|
bounds["south"] = f
|
||||||
|
}
|
||||||
|
if f, ok2 := v["east"].(float64); ok2 {
|
||||||
|
bounds["east"] = f
|
||||||
|
}
|
||||||
|
if f, ok2 := v["north"].(float64); ok2 {
|
||||||
|
bounds["north"] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resDeg := 0.0
|
||||||
|
if f, ok := meta["res_deg"].(float64); ok {
|
||||||
|
resDeg = f
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"z": z, "y": y, "x": x,
|
||||||
|
"bounds": bounds,
|
||||||
|
"res_deg": resDeg,
|
||||||
|
"grid": grid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func intFromMeta(m map[string]any, key string) int {
|
||||||
|
if v, ok := m[key]; ok {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(t)
|
||||||
|
case int:
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// 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)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "未找到最新雷达元数据"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var meta rf.Metadata
|
||||||
|
if err := json.Unmarshal(b, &meta); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析元数据失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// For each query candidate, call Caiyun
|
||||||
|
type Wind struct {
|
||||||
|
Speed float64 `json:"speed_ms"`
|
||||||
|
DirFrom float64 `json:"dir_from_deg"`
|
||||||
|
DirTo float64 `json:"dir_to_deg"`
|
||||||
|
U float64 `json:"u_east_ms"`
|
||||||
|
V float64 `json:"v_north_ms"`
|
||||||
|
TempC float64 `json:"temp_c"`
|
||||||
|
RH float64 `json:"rh"` // 0-1
|
||||||
|
PressureHpa float64 `json:"pressure_hpa"`
|
||||||
|
}
|
||||||
|
type CandOut struct {
|
||||||
|
rf.QueryCandidate
|
||||||
|
Wind *Wind `json:"wind,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
type ClusterAnal struct {
|
||||||
|
ClusterID int `json:"cluster_id"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
AreaPx int `json:"area_px"`
|
||||||
|
MaxDBZ float64 `json:"max_dbz"`
|
||||||
|
SpeedMS float64 `json:"speed_ms"`
|
||||||
|
DirToDeg float64 `json:"dir_to_deg"`
|
||||||
|
U float64 `json:"u_east_ms"`
|
||||||
|
V float64 `json:"v_north_ms"`
|
||||||
|
Coming bool `json:"coming"`
|
||||||
|
ETAMin float64 `json:"eta_min,omitempty"`
|
||||||
|
DistanceKm float64 `json:"distance_km"`
|
||||||
|
LateralKm float64 `json:"lateral_km"`
|
||||||
|
RCloudKm float64 `json:"r_cloud_km"`
|
||||||
|
}
|
||||||
|
analyses := []ClusterAnal{}
|
||||||
|
// 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 {
|
||||||
|
winds := agg[cl.ID]
|
||||||
|
if len(winds) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 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(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,
|
||||||
|
"candidates": outs,
|
||||||
|
"clusters": analyses,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func windVectorUV(speed, dirTo float64) (u, v float64) {
|
||||||
|
// dirTo: 0=north, 90=east
|
||||||
|
rad := dirTo * math.Pi / 180.0
|
||||||
|
u = speed * math.Sin(rad)
|
||||||
|
v = speed * math.Cos(rad)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func uvToDirTo(u, v float64) float64 {
|
||||||
|
// inverse of above
|
||||||
|
rad := math.Atan2(u, v) // atan2(y,x) but here y=u (east), x=v (north)
|
||||||
|
deg := rad * 180.0 / math.Pi
|
||||||
|
if deg < 0 {
|
||||||
|
deg += 360.0
|
||||||
|
}
|
||||||
|
return deg
|
||||||
|
}
|
||||||
|
|
||||||
|
func mathMod(a, m float64) float64 { // positive modulo
|
||||||
|
r := math.Mod(a, m)
|
||||||
|
if r < 0 {
|
||||||
|
r += m
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func round2(x float64) float64 { return math.Round(x*100.0) / 100.0 }
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
<script src="/static/js/chart.js"></script>
|
<script src="/static/js/chart.js"></script>
|
||||||
<link rel="stylesheet" href="/static/css/ol.css">
|
<link rel="stylesheet" href="/static/css/ol.css">
|
||||||
<script src="/static/js/ol.js"></script>
|
<script src="/static/js/ol.js"></script>
|
||||||
|
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
||||||
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
@ -239,6 +240,31 @@
|
|||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Radar view image normalization */
|
||||||
|
.radar-grid .img-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 75%;
|
||||||
|
margin: 0 auto;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.radar-grid .img-wrap { min-height: 220px; }
|
||||||
|
@media (min-width: 768px) { .radar-grid .img-wrap { min-height: 320px; } }
|
||||||
|
@media (min-width: 1024px) { .radar-grid .img-wrap { min-height: 360px; } }
|
||||||
|
.radar-grid .img-wrap img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.system-info {
|
.system-info {
|
||||||
background-color: #e9ecef;
|
background-color: #e9ecef;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@ -453,10 +479,20 @@
|
|||||||
|
|
||||||
<div class="container content-narrow py-5">
|
<div class="container content-narrow py-5">
|
||||||
<div class="system-info bg-gray-100 p-3 mb-5 rounded text-sm">
|
<div class="system-info bg-gray-100 p-3 mb-5 rounded text-sm">
|
||||||
<strong>在线设备: </strong> <span id="onlineDevices">{{.OnlineDevices}}</span> 个 |
|
<div class="flex items-center justify-between gap-3">
|
||||||
<strong>总设备: </strong> <a href="#" id="showDeviceList" class="text-blue-600 hover:text-blue-700 underline-offset-2" @click.prevent="deviceModalOpen = true; window.WeatherApp.updateDeviceList(1)"><span id="wh65lpCount">0</span> 个</a>
|
<div>
|
||||||
|
<strong>在线设备: </strong> <span id="onlineDevices">{{.OnlineDevices}}</span> 个 |
|
||||||
|
<strong>总设备: </strong>
|
||||||
|
<a href="#" id="showDeviceList" class="text-blue-600 hover:text-blue-700 underline-offset-2" @click.prevent="deviceModalOpen = true; window.WeatherApp.updateDeviceList(1)"><span id="wh65lpCount">0</span> 个</a>
|
||||||
|
</div>
|
||||||
|
<nav class="flex items-center gap-2">
|
||||||
|
<a id="tab-station" href="#station" class="px-3 py-1 rounded text-sm font-medium bg-blue-600 text-white">气象站</a>
|
||||||
|
<a id="tab-radar" href="#radar" class="px-3 py-1 rounded text-sm text-blue-700 hover:bg-blue-50">南宁雷达</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="view-station">
|
||||||
<div class="controls flex flex-col gap-4 mb-5 p-4 border rounded bg-white">
|
<div class="controls flex flex-col gap-4 mb-5 p-4 border rounded bg-white">
|
||||||
<div class="control-row flex items-center gap-4 flex-wrap">
|
<div class="control-row flex items-center gap-4 flex-wrap">
|
||||||
<div class="station-input-group flex items-center gap-1">
|
<div class="station-input-group flex items-center gap-1">
|
||||||
@ -558,11 +594,364 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="view-radar" style="display: none;">
|
||||||
|
<div class="bg-white border border-gray-200 rounded p-4 text-gray-700 radar-grid">
|
||||||
|
<div id="radarInfo" class="text-sm mb-3">正在加载最新雷达数据...</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 mb-3 text-sm">
|
||||||
|
<button id="radar-tab-china" class="px-3 py-1 rounded bg-blue-600 text-white">中国</button>
|
||||||
|
<button id="radar-tab-huanan" class="px-3 py-1 rounded bg-gray-100 text-blue-700 hover:bg-blue-50">华南</button>
|
||||||
|
<button id="radar-tab-nanning" class="px-3 py-1 rounded bg-gray-100 text-blue-700 hover:bg-blue-50">南宁</button>
|
||||||
|
<button id="radar-tab-cma" class="px-3 py-1 rounded bg-gray-100 text-blue-700 hover:bg-blue-50">CMA</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="img-wrap">
|
||||||
|
<img id="radar-main-img" alt="radar" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="radar-heat-section" class="mt-4">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">二维渲染(dBZ)</div>
|
||||||
|
<div class="w-full flex justify-center">
|
||||||
|
<div id="radar-heat-plot" style="width:75%;max-width:640px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.TIANDITU_KEY = '{{.TiandituKey}}';
|
window.TIANDITU_KEY = '{{.TiandituKey}}';
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
function getViewFromHash() {
|
||||||
|
return (location.hash || '#station').replace('#','');
|
||||||
|
}
|
||||||
|
function setActive(view) {
|
||||||
|
var stationView = document.getElementById('view-station');
|
||||||
|
var radarView = document.getElementById('view-radar');
|
||||||
|
var tabStation = document.getElementById('tab-station');
|
||||||
|
var tabRadar = document.getElementById('tab-radar');
|
||||||
|
|
||||||
|
var activeClasses = ['bg-blue-600', 'text-white', 'font-medium'];
|
||||||
|
var inactiveClasses = ['text-blue-700', 'hover:bg-blue-50'];
|
||||||
|
|
||||||
|
if (view === 'radar') {
|
||||||
|
stationView.style.display = 'none';
|
||||||
|
radarView.style.display = 'block';
|
||||||
|
tabStation.classList.remove.apply(tabStation.classList, activeClasses);
|
||||||
|
inactiveClasses.forEach(c=>tabStation.classList.add(c));
|
||||||
|
inactiveClasses.forEach(c=>tabRadar.classList.remove(c));
|
||||||
|
activeClasses.forEach(c=>tabRadar.classList.add(c));
|
||||||
|
} else {
|
||||||
|
stationView.style.display = 'block';
|
||||||
|
radarView.style.display = 'none';
|
||||||
|
tabRadar.classList.remove.apply(tabRadar.classList, activeClasses);
|
||||||
|
inactiveClasses.forEach(c=>tabRadar.classList.add(c));
|
||||||
|
inactiveClasses.forEach(c=>tabStation.classList.remove(c));
|
||||||
|
activeClasses.forEach(c=>tabStation.classList.add(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function initTabs() {
|
||||||
|
var view = getViewFromHash();
|
||||||
|
setActive(view);
|
||||||
|
if (view === 'radar') { loadRadarLatest(); loadPlotGrid(); }
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', initTabs);
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var ts = document.getElementById('tab-station');
|
||||||
|
var tr = document.getElementById('tab-radar');
|
||||||
|
if (ts) ts.addEventListener('click', function(e){ e.preventDefault(); location.hash = '#station';});
|
||||||
|
if (tr) tr.addEventListener('click', function(e){ e.preventDefault(); location.hash = '#radar';});
|
||||||
|
initTabs();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadRadarLatest() {
|
||||||
|
var infoEl = document.getElementById('radarInfo');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/radar/latest');
|
||||||
|
if (!res.ok) throw new Error('no data');
|
||||||
|
const data = await res.json();
|
||||||
|
const meta = data.meta || {};
|
||||||
|
const images = data.images || {};
|
||||||
|
infoEl.textContent = '时间: ' + (meta.timestamp_local || '未知');
|
||||||
|
window.RadarLatestImages = images;
|
||||||
|
setRadarImage('china');
|
||||||
|
bindRadarTabs();
|
||||||
|
} catch (e) {
|
||||||
|
infoEl.textContent = '暂无最新雷达数据';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindRadarTabs() {
|
||||||
|
var ids = ['china','huanan','nanning','cma'];
|
||||||
|
ids.forEach(function(k){
|
||||||
|
var el = document.getElementById('radar-tab-' + k);
|
||||||
|
if (el) el.onclick = function(){ setRadarImage(k); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRadarImage(kind) {
|
||||||
|
var images = window.RadarLatestImages || {};
|
||||||
|
var url = images[kind];
|
||||||
|
var img = document.getElementById('radar-main-img');
|
||||||
|
if (url) { img.src = url + '?t=' + Date.now(); }
|
||||||
|
// toggle active styles
|
||||||
|
var ids = ['china','huanan','nanning','cma'];
|
||||||
|
ids.forEach(function(k){
|
||||||
|
var el = document.getElementById('radar-tab-' + k);
|
||||||
|
if (!el) return;
|
||||||
|
if (k === kind) {
|
||||||
|
el.classList.add('bg-blue-600','text-white');
|
||||||
|
el.classList.remove('bg-gray-100','text-blue-700');
|
||||||
|
} else {
|
||||||
|
el.classList.remove('bg-blue-600','text-white');
|
||||||
|
el.classList.add('bg-gray-100','text-blue-700');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlotGrid(){
|
||||||
|
const res = await fetch('/api/radar/latest/grid');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
window.RadarLatestGrid = data;
|
||||||
|
renderPlotlyHeat(data);
|
||||||
|
renderClustersPanel();
|
||||||
|
renderWindQueryList();
|
||||||
|
renderWindResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlotlyHeat(payload){
|
||||||
|
// Preserve real dBZ values for z (so hover/scale show dBZ)
|
||||||
|
var z = (payload.grid || []).slice();
|
||||||
|
var colors = [
|
||||||
|
'#0000F6','#01A0F6','#00ECEC','#01FF00','#00C800',
|
||||||
|
'#019000','#FFFF00','#E7C000','#FF9000','#FF0000',
|
||||||
|
'#D60000','#C00000','#FF00F0','#780084','#AD90F0'
|
||||||
|
];
|
||||||
|
// Build step-like colorscale over dBZ domain [0, 5*(n-1)]
|
||||||
|
var zmin = 0;
|
||||||
|
var zmax = 5 * (colors.length - 1); // 70 for 15 colors
|
||||||
|
var colorscale = [];
|
||||||
|
for (var i=0;i<colors.length;i++){
|
||||||
|
var lo = (i*5)/zmax;
|
||||||
|
var hi = ((i+1)*5)/zmax;
|
||||||
|
if (i === colors.length-1) hi = 1.0;
|
||||||
|
// duplicate stops to create discrete bands
|
||||||
|
colorscale.push([lo, colors[i]]);
|
||||||
|
colorscale.push([hi, colors[i]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute lon/lat arrays from bounds + res for hover/axes
|
||||||
|
var w = (payload.bounds && typeof payload.bounds.west === 'number') ? payload.bounds.west : 0;
|
||||||
|
var s = (payload.bounds && typeof payload.bounds.south === 'number') ? payload.bounds.south : 0;
|
||||||
|
var e = (payload.bounds && typeof payload.bounds.east === 'number') ? payload.bounds.east : 256;
|
||||||
|
var n = (payload.bounds && typeof payload.bounds.north === 'number') ? payload.bounds.north : 256;
|
||||||
|
var res = (typeof payload.res_deg === 'number' && payload.res_deg > 0) ? payload.res_deg : ((e - w) / 256.0);
|
||||||
|
// Use pixel centers for coordinates
|
||||||
|
var xs = new Array(256);
|
||||||
|
var ys = new Array(256);
|
||||||
|
for (var xi = 0; xi < 256; xi++) { xs[xi] = w + (xi + 0.5) * res; }
|
||||||
|
for (var yi = 0; yi < 256; yi++) { ys[yi] = s + (yi + 0.5) * res; }
|
||||||
|
|
||||||
|
// Build customdata to carry pixel (x,y) indices for hover
|
||||||
|
var cd = new Array(256);
|
||||||
|
for (var r = 0; r < 256; r++) {
|
||||||
|
var row = new Array(256);
|
||||||
|
for (var c = 0; c < 256; c++) {
|
||||||
|
row[c] = [c, r];
|
||||||
|
}
|
||||||
|
cd[r] = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trace = {
|
||||||
|
z: z,
|
||||||
|
x: xs,
|
||||||
|
y: ys,
|
||||||
|
type: 'heatmap',
|
||||||
|
colorscale: colorscale,
|
||||||
|
colorbar: { title: 'dBZ', thickness: 18 },
|
||||||
|
zauto: false,
|
||||||
|
zmin: zmin,
|
||||||
|
zmax: zmax,
|
||||||
|
zsmooth: false,
|
||||||
|
customdata: cd,
|
||||||
|
hovertemplate: 'x=%{customdata[0]}, y=%{customdata[1]}<br>lon=%{x:.6f}, lat=%{y:.6f}<br>dBZ=%{z:.1f}<extra></extra>'
|
||||||
|
};
|
||||||
|
var box = document.getElementById('radar-heat-plot');
|
||||||
|
var size = Math.max(220, Math.min(520, Math.floor((box.clientWidth || 520))));
|
||||||
|
var cbx = 60; // approximate space for colorbar + padding
|
||||||
|
var layout = {
|
||||||
|
margin: {l:50, r:10, t:10, b:40},
|
||||||
|
xaxis: {
|
||||||
|
tickformat: '.3f',
|
||||||
|
range: [w, e]
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
tickformat: '.3f',
|
||||||
|
range: [s, n]
|
||||||
|
},
|
||||||
|
width: size + cbx,
|
||||||
|
height: size,
|
||||||
|
};
|
||||||
|
var config = {displayModeBar:false, responsive:true};
|
||||||
|
Plotly.newPlot('radar-heat-plot', [trace], layout, config).then(function(){
|
||||||
|
window.addEventListener('resize', function(){
|
||||||
|
var s2 = Math.max(220, Math.min(520, Math.floor((box.clientWidth || 520))));
|
||||||
|
Plotly.relayout('radar-heat-plot', {width: s2 + cbx, height: s2});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClustersPanel(){
|
||||||
|
// fetch meta to read clusters
|
||||||
|
fetch('/api/radar/latest').then(r=>r.json()).then(function(resp){
|
||||||
|
var meta = resp.meta || {};
|
||||||
|
var clusters = meta.clusters || [];
|
||||||
|
var host = '/radar/latest/';
|
||||||
|
var containerId = 'radar-clusters';
|
||||||
|
var parent = document.getElementById(containerId);
|
||||||
|
if (!parent) {
|
||||||
|
var sec = document.createElement('div');
|
||||||
|
sec.id = containerId;
|
||||||
|
sec.className = 'mt-4';
|
||||||
|
var root = document.getElementById('view-radar').querySelector('.radar-grid');
|
||||||
|
root.appendChild(sec);
|
||||||
|
parent = sec;
|
||||||
|
}
|
||||||
|
if (!clusters.length) { parent.innerHTML = '<div class="text-sm text-gray-500">暂无 >=40 dBZ 云团</div>'; return; }
|
||||||
|
var html = '<div class="text-sm text-gray-700 mb-2">云团(dBZ≥40)共 ' + clusters.length + ' 个</div>';
|
||||||
|
html += '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">';
|
||||||
|
clusters.forEach(function(cl){
|
||||||
|
var png = cl.png ? (host + cl.png) : '';
|
||||||
|
html += '<div class="border border-gray-200 rounded p-2">';
|
||||||
|
if (png) {
|
||||||
|
html += '<div class="mb-2 flex items-center justify-center" style="background:#fafafa">'
|
||||||
|
+ '<img src="'+png+'" style="image-rendering: pixelated; max-width: 100%; max-height: 120px;" />'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
html += '<div class="text-xs text-gray-600">'
|
||||||
|
+ 'ID: '+cl.id+' | 像元: '+cl.area_px+'<br/>'
|
||||||
|
+ '质心: '+cl.lon.toFixed(4)+', '+cl.lat.toFixed(4)+'<br/>'
|
||||||
|
+ 'dBZ: max '+cl.max_dbz.toFixed(1)+' / avg '+cl.avg_dbz.toFixed(1)
|
||||||
|
+ '</div>';
|
||||||
|
if (cl.samples && cl.samples.length) {
|
||||||
|
html += '<div class="mt-1 text-xs text-gray-600">采样点: ' + cl.samples.map(function(s){
|
||||||
|
return s.role+':('+s.lon.toFixed(3)+','+s.lat.toFixed(3)+')';
|
||||||
|
}).join(' | ') + '</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
parent.innerHTML = html;
|
||||||
|
}).catch(function(){ /* ignore */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWindQueryList(){
|
||||||
|
fetch('/api/radar/latest').then(r=>r.json()).then(function(resp){
|
||||||
|
var meta = resp.meta || {};
|
||||||
|
var params = meta.query_params || {};
|
||||||
|
var cands = meta.query_candidates || [];
|
||||||
|
var containerId = 'radar-wind-query';
|
||||||
|
var parent = document.getElementById(containerId);
|
||||||
|
if (!parent) {
|
||||||
|
var sec = document.createElement('div');
|
||||||
|
sec.id = containerId;
|
||||||
|
sec.className = 'mt-4';
|
||||||
|
var root = document.getElementById('view-radar').querySelector('.radar-grid');
|
||||||
|
root.appendChild(sec);
|
||||||
|
parent = sec;
|
||||||
|
}
|
||||||
|
var html = '<div class="text-sm text-gray-700 mb-2">风场查询参数</div>';
|
||||||
|
html += '<div class="text-xs text-gray-600 mb-2">'
|
||||||
|
+ 'min_area_px='+ (params.min_area_px||9)
|
||||||
|
+ ',strong_dbz_override=' + (params.strong_dbz_override||50)
|
||||||
|
+ ',max_samples_per_cluster=' + (params.max_samples_per_cluster||5)
|
||||||
|
+ ',max_candidates_total=' + (params.max_candidates_total||25)
|
||||||
|
+ '</div>';
|
||||||
|
if (!cands.length) {
|
||||||
|
html += '<div class="text-xs text-gray-500">暂无需要查询的采样点</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="text-sm text-gray-700 mb-1">需要查询的采样点(共 '+cands.length+' 个)</div>';
|
||||||
|
html += '<ul class="list-disc pl-5 text-xs text-gray-700">';
|
||||||
|
cands.forEach(function(p){
|
||||||
|
html += '<li>cluster='+p.cluster_id+' | '+p.role+' | lon='+p.lon.toFixed(4)+', lat='+p.lat.toFixed(4)+'</li>';
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
}
|
||||||
|
parent.innerHTML = html;
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWindResults(){
|
||||||
|
fetch('/api/radar/latest/wind').then(r=>r.json()).then(function(resp){
|
||||||
|
var station = resp.station || {};
|
||||||
|
var cands = resp.candidates || [];
|
||||||
|
var clusters = resp.clusters || [];
|
||||||
|
var containerId = 'radar-wind-results';
|
||||||
|
var parent = document.getElementById(containerId);
|
||||||
|
if (!parent) {
|
||||||
|
var sec = document.createElement('div');
|
||||||
|
sec.id = containerId;
|
||||||
|
sec.className = 'mt-4';
|
||||||
|
var root = document.getElementById('view-radar').querySelector('.radar-grid');
|
||||||
|
root.appendChild(sec);
|
||||||
|
parent = sec;
|
||||||
|
}
|
||||||
|
var html = '<div class="text-sm text-gray-700 mb-2">风场查询结果(彩云 10m 实况)</div>';
|
||||||
|
// cluster summary
|
||||||
|
if (clusters.length) {
|
||||||
|
html += '<div class="text-xs text-gray-700 mb-2">云团汇总:</div>';
|
||||||
|
html += '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mb-3">';
|
||||||
|
clusters.forEach(function(cl){
|
||||||
|
html += '<div class="border border-gray-200 rounded p-2 text-xs text-gray-700">'
|
||||||
|
+ 'ID '+cl.cluster_id+' | 距离 '+(cl.distance_km||0).toFixed(1)+' km<br/>'
|
||||||
|
+ '风 '+(cl.speed_ms||0).toFixed(1)+' m/s, 去向 '+(cl.dir_to_deg||0).toFixed(0)+'°<br/>'
|
||||||
|
+ (cl.coming?('<span class="text-green-700">朝向</span>, ETA '+(cl.eta_min||0).toFixed(1)+' 分钟'):'<span class="text-gray-500">非朝向</span>')
|
||||||
|
+ '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
// candidate details
|
||||||
|
if (cands.length) {
|
||||||
|
html += '<div class="text-xs text-gray-700 mb-2">采样点明细:</div>';
|
||||||
|
html += '<div class="overflow-x-auto"><table class="min-w-full text-xs text-gray-700"><thead><tr>'
|
||||||
|
+ '<th class="px-2 py-1 border">cluster</th>'
|
||||||
|
+ '<th class="px-2 py-1 border">role</th>'
|
||||||
|
+ '<th class="px-2 py-1 border">lon</th>'
|
||||||
|
+ '<th class="px-2 py-1 border">lat</th>'
|
||||||
|
+ '<th class="px-2 py-1 border">spd(m/s)</th>'
|
||||||
|
+ '<th class="px-2 py-1 border">dir_from(°)</th>'
|
||||||
|
+ '<th class="px-2 py-1 border">T(°C)</th>'
|
||||||
|
+ '<th class="px-2 py-1 border">RH</th>'
|
||||||
|
+ '<th class="px-2 py-1 border">P(hPa)</th>'
|
||||||
|
+ '<th class="px-2 py-1 border">err</th>'
|
||||||
|
+ '</tr></thead><tbody>';
|
||||||
|
cands.forEach(function(p){
|
||||||
|
var w = p.wind || {};
|
||||||
|
html += '<tr>'
|
||||||
|
+ '<td class="px-2 py-1 border">'+p.cluster_id+'</td>'
|
||||||
|
+ '<td class="px-2 py-1 border">'+p.role+'</td>'
|
||||||
|
+ '<td class="px-2 py-1 border">'+p.lon.toFixed(4)+'</td>'
|
||||||
|
+ '<td class="px-2 py-1 border">'+p.lat.toFixed(4)+'</td>'
|
||||||
|
+ '<td class="px-2 py-1 border">'+(w.speed_ms!=null?w.speed_ms.toFixed(1):'')+'</td>'
|
||||||
|
+ '<td class="px-2 py-1 border">'+(w.dir_from_deg!=null?w.dir_from_deg.toFixed(0):'')+'</td>'
|
||||||
|
+ '<td class="px-2 py-1 border">'+(w.temp_c!=null?w.temp_c.toFixed(1):'')+'</td>'
|
||||||
|
+ '<td class="px-2 py-1 border">'+(w.rh!=null?(w.rh*100).toFixed(0)+'%':'')+'</td>'
|
||||||
|
+ '<td class="px-2 py-1 border">'+(w.pressure_hpa!=null?w.pressure_hpa.toFixed(1):'')+'</td>'
|
||||||
|
+ '<td class="px-2 py-1 border">'+(p.error||'')+'</td>'
|
||||||
|
+ '</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
}
|
||||||
|
parent.innerHTML = html;
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<script defer src="/static/js/alpinejs.min.js"></script>
|
<script defer src="/static/js/alpinejs.min.js"></script>
|
||||||
<script src="/static/js/utils.js"></script>
|
<script src="/static/js/utils.js"></script>
|
||||||
<script src="/static/js/weather-app.js"></script>
|
<script src="/static/js/weather-app.js"></script>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user