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