Revert "feat: 新增雷达图"

This reverts commit 448b13c2f6eb9b505e516858c27b571eafca1879.
This commit is contained in:
yarnom 2025-09-23 09:33:12 +08:00
parent a03c60469f
commit da67660fe7
10 changed files with 2 additions and 964 deletions

View File

@ -1,47 +0,0 @@
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)
}

View File

@ -1,37 +0,0 @@
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
}

View File

@ -1,202 +0,0 @@
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()
}

View File

@ -1,51 +0,0 @@
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])
}

View File

@ -1,33 +0,0 @@
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
}

View File

@ -1,97 +0,0 @@
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)" }

View File

@ -1,89 +0,0 @@
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
}

View File

@ -1,37 +0,0 @@
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
}

View File

@ -1,12 +1,9 @@
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"
@ -29,8 +26,6 @@ func StartGinServer() error {
// 静态文件服务 // 静态文件服务
r.Static("/static", "./static") r.Static("/static", "./static")
// 雷达数据静态目录(用于访问 latest 下的图片/二进制)
r.Static("/radar", "./radar_data")
// 路由设置 // 路由设置
r.GET("/", indexHandler) r.GET("/", indexHandler)
@ -42,8 +37,6 @@ 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)
} }
// 获取配置的Web端口 // 获取配置的Web端口
@ -52,8 +45,6 @@ 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))
@ -70,8 +61,6 @@ func indexHandler(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", data) c.HTML(http.StatusOK, "index.html", data)
} }
// 备注:雷达站采用前端 Tabhash切换无需单独路由
// systemStatusHandler 处理系统状态API请求 // systemStatusHandler 处理系统状态API请求
func systemStatusHandler(c *gin.Context) { func systemStatusHandler(c *gin.Context) {
status := types.SystemStatus{ status := types.SystemStatus{
@ -215,120 +204,3 @@ 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
}

View File

@ -7,7 +7,6 @@
<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 {
@ -240,31 +239,6 @@
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;
@ -479,20 +453,10 @@
<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">
<div class="flex items-center justify-between gap-3">
<div>
<strong>在线设备: </strong> <span id="onlineDevices">{{.OnlineDevices}}</span> 个 | <strong>在线设备: </strong> <span id="onlineDevices">{{.OnlineDevices}}</span> 个 |
<strong>总设备: </strong> <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>
<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">
@ -596,214 +560,9 @@
</div> </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>
<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);
}
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});
});
});
}
})();
</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>