diff --git a/cmd/radar/main.go b/cmd/radar/main.go
new file mode 100644
index 0000000..b22fd2e
--- /dev/null
+++ b/cmd/radar/main.go
@@ -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)
+}
diff --git a/internal/radarfetch/fetch.go b/internal/radarfetch/fetch.go
new file mode 100644
index 0000000..3e0f629
--- /dev/null
+++ b/internal/radarfetch/fetch.go
@@ -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
+}
diff --git a/internal/radarfetch/job.go b/internal/radarfetch/job.go
new file mode 100644
index 0000000..32eb096
--- /dev/null
+++ b/internal/radarfetch/job.go
@@ -0,0 +1,202 @@
+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)
+ }
+
+ // 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),
+ }
+ 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()
+}
diff --git a/internal/radarfetch/parse.go b/internal/radarfetch/parse.go
new file mode 100644
index 0000000..ab63ad8
--- /dev/null
+++ b/internal/radarfetch/parse.go
@@ -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])
+}
diff --git a/internal/radarfetch/radar.go b/internal/radarfetch/radar.go
new file mode 100644
index 0000000..3cb7ca5
--- /dev/null
+++ b/internal/radarfetch/radar.go
@@ -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
+}
diff --git a/internal/radarfetch/render.go b/internal/radarfetch/render.go
new file mode 100644
index 0000000..ba8f10b
--- /dev/null
+++ b/internal/radarfetch/render.go
@@ -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)" }
diff --git a/internal/radarfetch/store.go b/internal/radarfetch/store.go
new file mode 100644
index 0000000..10e658f
--- /dev/null
+++ b/internal/radarfetch/store.go
@@ -0,0 +1,89 @@
+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"`
+}
+
+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 {
+ _ = 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))
+ }
+ return nil
+}
diff --git a/internal/radarfetch/timeutil.go b/internal/radarfetch/timeutil.go
new file mode 100644
index 0000000..a0b5f59
--- /dev/null
+++ b/internal/radarfetch/timeutil.go
@@ -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
+}
diff --git a/internal/server/gin.go b/internal/server/gin.go
index ad36cb2..b2f4b67 100644
--- a/internal/server/gin.go
+++ b/internal/server/gin.go
@@ -1,9 +1,12 @@
package server
import (
+ "encoding/json"
"fmt"
"log"
"net/http"
+ "os"
+ "path"
"strconv"
"time"
"weatherstation/internal/config"
@@ -26,6 +29,8 @@ func StartGinServer() error {
// 静态文件服务
r.Static("/static", "./static")
+ // 雷达数据静态目录(用于访问 latest 下的图片/二进制)
+ r.Static("/radar", "./radar_data")
// 路由设置
r.GET("/", indexHandler)
@@ -37,6 +42,8 @@ func StartGinServer() error {
api.GET("/stations", getStationsHandler)
api.GET("/data", getDataHandler)
api.GET("/forecast", getForecastHandler)
+ api.GET("/radar/latest", radarLatestHandler)
+ api.GET("/radar/latest/grid", radarLatestGridHandler)
}
// 获取配置的Web端口
@@ -45,6 +52,8 @@ func StartGinServer() error {
port = 10003 // 默认端口
}
+ // 备注:雷达抓取改为独立 CLI 触发,Web 服务不自动启动后台任务
+
// 启动服务器
fmt.Printf("Gin Web服务器启动,监听端口 %d...\n", port)
return r.Run(fmt.Sprintf(":%d", port))
@@ -61,6 +70,8 @@ func indexHandler(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", data)
}
+// 备注:雷达站采用前端 Tab(hash)切换,无需单独路由
+
// systemStatusHandler 处理系统状态API请求
func systemStatusHandler(c *gin.Context) {
status := types.SystemStatus{
@@ -204,3 +215,120 @@ func getForecastHandler(c *gin.Context) {
log.Printf("查询到预报数据: %d 条", len(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
+}
diff --git a/templates/index.html b/templates/index.html
index ecef289..4f36a18 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -7,6 +7,7 @@
+