From 28ce15ce13c43d0dce73b4731dc4a92abb592e6a Mon Sep 17 00:00:00 2001 From: yarnom Date: Tue, 7 Oct 2025 12:31:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=9B=A8=E9=87=8F?= =?UTF-8?q?=E7=93=A6=E7=89=87=E7=9A=84=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/weatherstation/main.go | 20 ++ internal/database/rain_tiles.go | 77 ++++++++ internal/rain/scheduler.go | 231 ++++++++++++++++++++++++ internal/rain/store.go | 70 +++++++ internal/server/gin.go | 5 + internal/server/radar_api.go | 14 +- internal/server/rain_api.go | 311 ++++++++++++++++++++++++++++++++ templates/imdroid_radar.html | 248 ++++++++++++++++++++++++- 8 files changed, 970 insertions(+), 6 deletions(-) create mode 100644 internal/database/rain_tiles.go create mode 100644 internal/rain/scheduler.go create mode 100644 internal/rain/store.go create mode 100644 internal/server/rain_api.go diff --git a/cmd/weatherstation/main.go b/cmd/weatherstation/main.go index 0e00836..c48eaf7 100644 --- a/cmd/weatherstation/main.go +++ b/cmd/weatherstation/main.go @@ -11,6 +11,7 @@ import ( "weatherstation/internal/database" "weatherstation/internal/forecast" "weatherstation/internal/radar" + "weatherstation/internal/rain" "weatherstation/internal/selftest" "weatherstation/internal/server" "weatherstation/internal/tools" @@ -255,10 +256,27 @@ func main() { }() } + startRainSchedulerBackground := func(wg *sync.WaitGroup) { + if wg != nil { + wg.Add(1) + } + go func() { + defer func() { + if wg != nil { + wg.Done() + } + }() + log.Println("启动一小时降雨下载任务(每10分钟,固定瓦片 7/40/102 与 7/40/104)...") + ctx := context.Background() + _ = rain.Start(ctx, rain.Options{StoreToDB: true}) + }() + } + if *webOnly { // 只启动Web服务器 + 导出器 startExporterBackground(nil) startRadarSchedulerBackground(nil) + startRainSchedulerBackground(nil) log.Println("启动Web服务器模式...") if err := server.StartGinServer(); err != nil { log.Fatalf("启动Web服务器失败: %v", err) @@ -267,6 +285,7 @@ func main() { // 只启动UDP服务器 + 导出器 startExporterBackground(nil) startRadarSchedulerBackground(nil) + startRainSchedulerBackground(nil) log.Println("启动UDP服务器模式...") if err := server.StartUDPServer(); err != nil { log.Fatalf("启动UDP服务器失败: %v", err) @@ -298,6 +317,7 @@ func main() { startExporterBackground(&wg) startRadarSchedulerBackground(&wg) + startRainSchedulerBackground(&wg) wg.Wait() } } diff --git a/internal/database/rain_tiles.go b/internal/database/rain_tiles.go new file mode 100644 index 0000000..bf55e02 --- /dev/null +++ b/internal/database/rain_tiles.go @@ -0,0 +1,77 @@ +package database + +import ( + "context" + "crypto/md5" + "database/sql" + "encoding/hex" + "fmt" + "math" + "time" +) + +// UpsertRainTile stores a rain tile into table `rain_tiles`. +// The tiling scheme is equal-angle EPSG:4326 like radar tiles. +func UpsertRainTile(ctx context.Context, db *sql.DB, product string, dt time.Time, z, y, x int, width, height int, data []byte) error { + if width == 0 { + width = 256 + } + if height == 0 { + height = 256 + } + step := 360.0 / math.Pow(2, float64(z)) + west := -180.0 + float64(x)*step + south := -90.0 + float64(y)*step + east := west + step + north := south + step + res := step / float64(width) + + sum := md5.Sum(data) + md5hex := hex.EncodeToString(sum[:]) + + q := ` + INSERT INTO rain_tiles ( + product, dt, z, y, x, width, height, + west, south, east, north, res_deg, + data, checksum_md5 + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7, + $8,$9,$10,$11,$12, + $13,$14 + ) + ON CONFLICT (product, dt, z, y, x) + DO UPDATE SET + width = EXCLUDED.width, + height = EXCLUDED.height, + west = EXCLUDED.west, + south = EXCLUDED.south, + east = EXCLUDED.east, + north = EXCLUDED.north, + res_deg = EXCLUDED.res_deg, + data = EXCLUDED.data, + checksum_md5 = EXCLUDED.checksum_md5` + + _, err := db.ExecContext(ctx, q, + product, dt, z, y, x, width, height, + west, south, east, north, res, + data, md5hex, + ) + if err != nil { + return fmt.Errorf("upsert rain tile (%s %s z=%d y=%d x=%d): %w", product, dt.Format(time.RFC3339), z, y, x, err) + } + return nil +} + +// HasRainTile reports whether a rain tile exists for the given key. +func HasRainTile(ctx context.Context, db *sql.DB, product string, dt time.Time, z, y, x int) (bool, error) { + const q = `SELECT 1 FROM rain_tiles WHERE product=$1 AND dt=$2 AND z=$3 AND y=$4 AND x=$5 LIMIT 1` + var one int + err := db.QueryRowContext(ctx, q, product, dt, z, y, x).Scan(&one) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("check rain tile exists: %w", err) + } + return true, nil +} diff --git a/internal/rain/scheduler.go b/internal/rain/scheduler.go new file mode 100644 index 0000000..edc4ed6 --- /dev/null +++ b/internal/rain/scheduler.go @@ -0,0 +1,231 @@ +package rain + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "weatherstation/internal/database" +) + +// Options controls the rain (CMPA hourly precip) scheduler behavior. +type Options struct { + Enable bool + OutputDir string + BaseURL string // template with %Y%m%d/%H/%M and {z}/{y}/{x}; time is UTC + MaxRetries int + StoreToDB bool + Tiles [][3]int // list of (z,y,x); defaults to [[7,40,102],[7,40,104]] +} + +// Start starts the CMPA hourly rain tile downloader. +// Runs every 10 minutes aligned to 10-minute boundaries. For each tick at local time T, +// constructs the slot as floor_to_hour(T) - 1h (last completed hour), converts to UTC +// (slot_utc = slot_local - 8h), and downloads 0-minute tile for each configured (z,y,x). +func Start(ctx context.Context, opts Options) error { + if !opts.Enable && !envEnabledDefaultTrue() { + log.Println("[rain] scheduler disabled") + return nil + } + if opts.OutputDir == "" { + opts.OutputDir = getenvDefault("RAIN_DIR", "rain_data") + } + if opts.MaxRetries == 0 { + opts.MaxRetries = getenvIntDefault("RAIN_MAX_RETRIES", 2) + } + if opts.BaseURL == "" { + opts.BaseURL = getenvDefault("RAIN_BASE_URL", "https://image.data.cma.cn/tiles/China/CMPA_RT_China_0P01_HOR-PRE_GISJPG_Tiles/%Y%m%d/%H/%M/{z}/{y}/{x}.bin") + } + if len(opts.Tiles) == 0 { + opts.Tiles = [][3]int{{7, 40, 102}, {7, 40, 104}} + } + if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil { + return fmt.Errorf("create rain output dir: %w", err) + } + + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = time.FixedZone("CST", 8*3600) + } + + // immediate first run + go func() { + if err := runOnce(ctx, opts, loc); err != nil { + log.Printf("[rain] first run error: %v", err) + } + }() + + // every 10 minutes + go func() { + for { + if ctx.Err() != nil { + return + } + now := time.Now().In(loc) + runAt := roundDownN(now, 10*time.Minute).Add(10 * time.Minute) + sleep := time.Until(runAt) + if sleep < 0 { + sleep = 0 + } + timer := time.NewTimer(sleep) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + if err := runOnce(ctx, opts, loc); err != nil { + log.Printf("[rain] run error: %v", err) + } + } + } + }() + + log.Printf("[rain] scheduler started (10m, dir=%s, tiles=%d)", opts.OutputDir, len(opts.Tiles)) + return nil +} + +func runOnce(ctx context.Context, opts Options, loc *time.Location) error { + // target hour: current hour at 00 (floor_to_hour(now)) + // e.g., 10:15 -> 10:00; if尚未发布则下载可能失败,等待下一次10分钟重试 + now := time.Now().In(loc) + slotLocal := now.Truncate(time.Hour) + // UTC for URL path + slotUTC := slotLocal.Add(-8 * time.Hour).In(time.UTC) + log.Printf("[rain] tick target hour: local=%s (CST), utc=%s (UTC)", slotLocal.Format("2006-01-02 15:04"), slotUTC.Format("2006-01-02 15:04")) + dateStr := slotUTC.Format("20060102") + hh := slotUTC.Format("15") + mm := "00" // hourly product uses minute 00 + + for _, t := range opts.Tiles { + z, y, x := t[0], t[1], t[2] + if err := downloadAndStoreTile(ctx, slotLocal, dateStr, hh, mm, z, y, x, opts); err != nil { + log.Printf("[rain] download/store z=%d y=%d x=%d failed: %v", z, y, x, err) + } + } + return nil +} + +func downloadAndStoreTile(ctx context.Context, local time.Time, dateStr, hh, mm string, z, y, x int, opts Options) error { + url := fmt.Sprintf("https://image.data.cma.cn/tiles/China/CMPA_RT_China_0P01_HOR-PRE_GISJPG_Tiles/%s/%s/%s/%d/%d/%d.bin", dateStr, hh, mm, z, y, x) + // skip if exists in DB + if ref, err := ParseCMPATileURL(url); err == nil { + exists, err := databaseHas(ctx, ref.Product, ref.DT, z, y, x) + if err != nil { + return err + } + if exists { + log.Printf("[rain] skip: already in DB z=%d y=%d x=%d dt(local)=%s url=%s", z, y, x, ref.DT.Format("2006-01-02 15:04"), url) + return nil + } + } + + fname := fmt.Sprintf("rain_z%d_y%d_x%d_%s.bin", z, y, x, local.Format("20060102_1504")) + dest := filepath.Join(opts.OutputDir, fname) + if _, err := os.Stat(dest); err == nil { + log.Printf("[rain] skip: file exists %s", dest) + return nil + } + + if err := httpDownloadTo(ctx, url, dest); err != nil { + return err + } + log.Printf("[rain] saved %s (url=%s)", dest, url) + + if opts.StoreToDB { + b, rerr := os.ReadFile(dest) + if rerr != nil { + return fmt.Errorf("read saved tile: %w", rerr) + } + if err := StoreTileBytes(ctx, url, b); err != nil { + return fmt.Errorf("store tile db: %w", err) + } + log.Printf("[rain] stored to DB: %s", fname) + } + return nil +} + +func httpDownloadTo(ctx context.Context, url, dest string) error { + client := &http.Client{Timeout: 20 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Referer", "https://data.cma.cn/") + req.Header.Set("Origin", "https://data.cma.cn") + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36") + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("http get: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + tmp := dest + ".part" + f, err := os.Create(tmp) + if err != nil { + return fmt.Errorf("create temp: %w", err) + } + _, copyErr := io.Copy(f, resp.Body) + closeErr := f.Close() + if copyErr != nil { + _ = os.Remove(tmp) + return fmt.Errorf("write body: %w", copyErr) + } + if closeErr != nil { + _ = os.Remove(tmp) + return fmt.Errorf("close temp: %w", closeErr) + } + if err := os.Rename(tmp, dest); err != nil { + // Cross-device fallback + if !errors.Is(err, os.ErrInvalid) { + return fmt.Errorf("rename: %w", err) + } + data, rerr := os.ReadFile(tmp) + if rerr != nil { + return fmt.Errorf("read temp: %w", rerr) + } + if werr := os.WriteFile(dest, data, 0o644); werr != nil { + return fmt.Errorf("write final: %w", werr) + } + _ = os.Remove(tmp) + } + return nil +} + +// small env helpers (duplicated minimal set to avoid cross-package deps) +func getenvDefault(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} +func getenvIntDefault(k string, def int) int { + if v := os.Getenv(k); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} +func envEnabledDefaultTrue() bool { + v := strings.ToLower(os.Getenv("RAIN_ENABLED")) + if v == "" { + return true + } + return v == "1" || v == "true" || v == "yes" +} + +func databaseHas(ctx context.Context, product string, dt time.Time, z, y, x int) (bool, error) { + return database.HasRainTile(ctx, database.GetDB(), product, dt, z, y, x) +} + +func roundDownN(t time.Time, d time.Duration) time.Time { return t.Truncate(d) } diff --git a/internal/rain/store.go b/internal/rain/store.go new file mode 100644 index 0000000..f5f59ad --- /dev/null +++ b/internal/rain/store.go @@ -0,0 +1,70 @@ +package rain + +import ( + "context" + "fmt" + "path" + "regexp" + "strconv" + "strings" + "time" + "weatherstation/internal/database" +) + +var ( + // Matches ...///////.bin + // Example: /tiles/China/CMPA_RT_China_0P01_HOR-PRE_GISJPG_Tiles/20251007/01/00/7/40/102.bin + tileRE = regexp.MustCompile(`(?i)/tiles/.+?/([^/]+)/([0-9]{8})/([0-9]{2})/([0-9]{2})/([0-9]+)/([0-9]+)/([0-9]+)\.bin$`) +) + +// TileRef references a CMPA rain tile. +type TileRef struct { + Product string + DT time.Time // nominal time in Asia/Shanghai (UTC+8) + Z, Y, X int +} + +// ParseCMPATileURL parses a CMPA tile URL/path and extracts product, time (UTC+8), z/y/x. +// The timestamp in the path is UTC; we convert to Asia/Shanghai by adding 8h. +func ParseCMPATileURL(u string) (TileRef, error) { + p := u + if i := strings.IndexAny(p, "?#"); i >= 0 { + p = p[:i] + } + p = path.Clean(p) + m := tileRE.FindStringSubmatch(p) + if len(m) == 0 { + return TileRef{}, fmt.Errorf("unrecognized CMPA tile path: %s", u) + } + product := m[1] + yyyymmdd := m[2] + hh := m[3] + mm := m[4] + z := mustAtoi(m[5]) + y := mustAtoi(m[6]) + x := mustAtoi(m[7]) + + // Parse as UTC then shift to CST(+8) + utcT, err := time.ParseInLocation("20060102 15 04", fmt.Sprintf("%s %s %s", yyyymmdd, hh, mm), time.UTC) + if err != nil { + return TileRef{}, fmt.Errorf("parse utc time: %w", err) + } + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = time.FixedZone("CST", 8*3600) + } + dt := utcT.In(loc) + return TileRef{Product: product, DT: dt, Z: z, Y: y, X: x}, nil +} + +func mustAtoi(s string) int { n, _ := strconv.Atoi(s); return n } + +// StoreTileBytes parses the URL, computes metadata and upserts into rain_tiles. +func StoreTileBytes(ctx context.Context, urlOrPath string, data []byte) error { + ref, err := ParseCMPATileURL(urlOrPath) + if err != nil { + return err + } + db := database.GetDB() + return database.UpsertRainTile(ctx, db, ref.Product, ref.DT, ref.Z, ref.Y, ref.X, 256, 256, data) +} diff --git a/internal/server/gin.go b/internal/server/gin.go index 5759140..147ae20 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -51,6 +51,11 @@ func StartGinServer() error { api.GET("/radar/weather_aliases", radarWeatherAliasesHandler) api.GET("/radar/aliases", radarConfigAliasesHandler) api.GET("/radar/weather_nearest", radarWeatherNearestHandler) + // Rain CMPA hourly tiles + api.GET("/rain/latest", latestRainTileHandler) + api.GET("/rain/at", rainTileAtHandler) + api.GET("/rain/nearest", nearestRainTileHandler) + api.GET("/rain/times", rainTileTimesHandler) } // 获取配置的Web端口 diff --git a/internal/server/radar_api.go b/internal/server/radar_api.go index 65bd048..2895c0c 100644 --- a/internal/server/radar_api.go +++ b/internal/server/radar_api.go @@ -121,8 +121,12 @@ func latestRadarTileHandler(c *gin.Context) { vals[row] = rowVals } + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = time.FixedZone("CST", 8*3600) + } resp := radarTileResponse{ - DT: rec.DT.Format("2006-01-02 15:04:05"), + DT: rec.DT.In(loc).Format("2006-01-02 15:04:05"), Z: rec.Z, Y: rec.Y, X: rec.X, @@ -189,8 +193,9 @@ func radarTileAtHandler(c *gin.Context) { } vals[row] = rowVals } + // 统一以 CST(+8) 输出 resp := radarTileResponse{ - DT: rec.DT.Format("2006-01-02 15:04:05"), + DT: rec.DT.In(loc).Format("2006-01-02 15:04:05"), Z: rec.Z, Y: rec.Y, X: rec.X, @@ -251,7 +256,7 @@ func radarTileTimesHandler(c *gin.Context) { if err := rows.Scan(&dt); err != nil { continue } - times = append(times, dt.Format("2006-01-02 15:04:05")) + times = append(times, dt.In(loc).Format("2006-01-02 15:04:05")) } c.JSON(http.StatusOK, gin.H{"times": times}) } @@ -337,8 +342,9 @@ func nearestRadarTileHandler(c *gin.Context) { } vals[rowi] = rowVals } + // 统一以 CST(+8) 输出 resp := radarTileResponse{ - DT: rec.DT.Format("2006-01-02 15:04:05"), + DT: rec.DT.In(loc).Format("2006-01-02 15:04:05"), Z: rec.Z, Y: rec.Y, X: rec.X, diff --git a/internal/server/rain_api.go b/internal/server/rain_api.go new file mode 100644 index 0000000..9ed7079 --- /dev/null +++ b/internal/server/rain_api.go @@ -0,0 +1,311 @@ +package server + +import ( + "database/sql" + "encoding/binary" + "net/http" + "time" + "weatherstation/internal/database" + + "github.com/gin-gonic/gin" +) + +type rainTileRecord struct { + DT time.Time + Z int + Y int + X int + Width int + Height int + West float64 + South float64 + East float64 + North float64 + ResDeg float64 + Data []byte +} + +type rainTileResponse struct { + DT string `json:"dt"` + Z int `json:"z"` + Y int `json:"y"` + X int `json:"x"` + Width int `json:"width"` + Height int `json:"height"` + West float64 `json:"west"` + South float64 `json:"south"` + East float64 `json:"east"` + North float64 `json:"north"` + ResDeg float64 `json:"res_deg"` + Values [][]*float64 `json:"values"` // 单位:mm,null 表示无效 +} + +func getLatestRainTile(db *sql.DB, z, y, x int) (*rainTileRecord, error) { + const q = ` + SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data + FROM rain_tiles + WHERE z=$1 AND y=$2 AND x=$3 + ORDER BY dt DESC + LIMIT 1` + var r rainTileRecord + err := db.QueryRow(q, z, y, x).Scan( + &r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height, + &r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data, + ) + if err != nil { + return nil, err + } + return &r, nil +} + +func getRainTileAt(db *sql.DB, z, y, x int, dt time.Time) (*rainTileRecord, error) { + const q = ` + SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data + FROM rain_tiles + WHERE z=$1 AND y=$2 AND x=$3 AND dt=$4 + LIMIT 1` + var r rainTileRecord + err := db.QueryRow(q, z, y, x, dt).Scan( + &r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height, + &r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data, + ) + if err != nil { + return nil, err + } + return &r, nil +} + +// latestRainTileHandler 返回指定 z/y/x 的最新一小时降雨瓦片 +func latestRainTileHandler(c *gin.Context) { + z := parseIntDefault(c.Query("z"), 7) + y := parseIntDefault(c.Query("y"), 40) + x := parseIntDefault(c.Query("x"), 102) + + rec, err := getLatestRainTile(database.GetDB(), z, y, x) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "未找到雨量瓦片"}) + return + } + w, h := rec.Width, rec.Height + if len(rec.Data) < w*h*2 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "数据长度异常"}) + return + } + vals := decodeRain(rec.Data, w, h) + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = time.FixedZone("CST", 8*3600) + } + resp := rainTileResponse{ + DT: rec.DT.In(loc).Format("2006-01-02 15:04:05"), + Z: rec.Z, + Y: rec.Y, + X: rec.X, + Width: rec.Width, + Height: rec.Height, + West: rec.West, + South: rec.South, + East: rec.East, + North: rec.North, + ResDeg: rec.ResDeg, + Values: vals, + } + c.JSON(http.StatusOK, resp) +} + +// rainTileAtHandler 返回指定 z/y/x 的指定时间(CST)瓦片 +func rainTileAtHandler(c *gin.Context) { + z := parseIntDefault(c.Query("z"), 7) + y := parseIntDefault(c.Query("y"), 40) + x := parseIntDefault(c.Query("x"), 102) + dtStr := c.Query("dt") + if dtStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 dt 参数(YYYY-MM-DD HH:MM:SS)"}) + return + } + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = time.FixedZone("CST", 8*3600) + } + dt, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"}) + return + } + rec, err := getRainTileAt(database.GetDB(), z, y, x, dt) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "未找到指定时间瓦片"}) + return + } + w, h := rec.Width, rec.Height + if len(rec.Data) < w*h*2 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "数据长度异常"}) + return + } + vals := decodeRain(rec.Data, w, h) + // 始终以 CST(+8) 输出 + resp := rainTileResponse{ + DT: rec.DT.In(loc).Format("2006-01-02 15:04:05"), + Z: rec.Z, + Y: rec.Y, + X: rec.X, + Width: rec.Width, + Height: rec.Height, + West: rec.West, + South: rec.South, + East: rec.East, + North: rec.North, + ResDeg: rec.ResDeg, + Values: vals, + } + c.JSON(http.StatusOK, resp) +} + +// rainTileTimesHandler 返回指定 z/y/x 的可用时间列表(倒序) +func rainTileTimesHandler(c *gin.Context) { + z := parseIntDefault(c.Query("z"), 7) + y := parseIntDefault(c.Query("y"), 40) + x := parseIntDefault(c.Query("x"), 102) + fromStr := c.Query("from") + toStr := c.Query("to") + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = time.FixedZone("CST", 8*3600) + } + var rows *sql.Rows + var err error + if fromStr != "" && toStr != "" { + from, err1 := time.ParseInLocation("2006-01-02 15:04:05", fromStr, loc) + to, err2 := time.ParseInLocation("2006-01-02 15:04:05", toStr, loc) + if err1 != nil || err2 != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 from/to 时间格式"}) + return + } + const qRange = ` + SELECT dt FROM rain_tiles + WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5 + ORDER BY dt DESC` + rows, err = database.GetDB().Query(qRange, z, y, x, from, to) + } else { + limit := parseIntDefault(c.Query("limit"), 48) + const q = ` + SELECT dt FROM rain_tiles + WHERE z=$1 AND y=$2 AND x=$3 + ORDER BY dt DESC + LIMIT $4` + rows, err = database.GetDB().Query(q, z, y, x, limit) + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "查询时间列表失败"}) + return + } + defer rows.Close() + var times []string + for rows.Next() { + var dt time.Time + if err := rows.Scan(&dt); err != nil { + continue + } + times = append(times, dt.In(loc).Format("2006-01-02 15:04:05")) + } + c.JSON(http.StatusOK, gin.H{"times": times}) +} + +// nearestRainTileHandler 返回最接近给定时间的瓦片 +func nearestRainTileHandler(c *gin.Context) { + z := parseIntDefault(c.Query("z"), 7) + y := parseIntDefault(c.Query("y"), 40) + x := parseIntDefault(c.Query("x"), 102) + dtStr := c.Query("dt") + if dtStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 dt 参数(YYYY-MM-DD HH:MM:SS)"}) + return + } + tolMin := parseIntDefault(c.Query("tolerance_min"), 90) + prefer := c.DefaultQuery("prefer", "lte") // lte|nearest + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = time.FixedZone("CST", 8*3600) + } + target, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"}) + return + } + from := target.Add(-time.Duration(tolMin) * time.Minute) + to := target.Add(time.Duration(tolMin) * time.Minute) + + db := database.GetDB() + var row *sql.Row + if prefer == "lte" { + const q = ` + SELECT dt FROM rain_tiles + WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5 AND dt <= $6 + ORDER BY ($6 - dt) ASC + LIMIT 1` + row = db.QueryRow(q, z, y, x, from, to, target) + } else { + const q = ` + SELECT dt FROM rain_tiles + WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5 + ORDER BY ABS(EXTRACT(EPOCH FROM (dt - $6))) ASC + LIMIT 1` + row = db.QueryRow(q, z, y, x, from, to, target) + } + var picked time.Time + if err := row.Scan(&picked); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "在容差范围内未找到匹配瓦片"}) + return + } + rec, err := getRainTileAt(db, z, y, x, picked) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "未找到匹配瓦片数据"}) + return + } + w, h := rec.Width, rec.Height + if len(rec.Data) < w*h*2 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "数据长度异常"}) + return + } + vals := decodeRain(rec.Data, w, h) + // 以 CST(+8) 输出 + resp := rainTileResponse{ + DT: rec.DT.In(loc).Format("2006-01-02 15:04:05"), + Z: rec.Z, + Y: rec.Y, + X: rec.X, + Width: rec.Width, + Height: rec.Height, + West: rec.West, + South: rec.South, + East: rec.East, + North: rec.North, + ResDeg: rec.ResDeg, + Values: vals, + } + c.JSON(http.StatusOK, resp) +} + +func decodeRain(buf []byte, w, h int) [][]*float64 { + vals := make([][]*float64, h) + off := 0 + for row := 0; row < h; row++ { + rowVals := make([]*float64, w) + for col := 0; col < w; col++ { + v := int16(binary.BigEndian.Uint16(buf[off : off+2])) + off += 2 + if v >= 32766 { // 无效 + rowVals[col] = nil + continue + } + mm := float64(v) / 10.0 // 0.1 mm 精度 + if mm < 0 { + mm = 0 + } + vv := mm + rowVals[col] = &vv + } + vals[row] = rowVals + } + return vals +} diff --git a/templates/imdroid_radar.html b/templates/imdroid_radar.html index aaccd17..9ad0097 100644 --- a/templates/imdroid_radar.html +++ b/templates/imdroid_radar.html @@ -19,10 +19,13 @@ let gTimes = []; let gCurrentIdx = -1; let gAlias = ''; + // 雨量瓦片(小时累计)当前展示的时间,便于状态同步 + let gRainDT = ''; // 3H 预报相关全局量 let gTileValues = null, gXs = null, gYs = null; let gWindFromDeg = null, gWindSpeedMS = null; let gTileDT = null; + let gTileDTStr = ''; let gStLat = null, gStLon = null; async function loadStations() { @@ -149,6 +152,8 @@ document.getElementById('tile_res').textContent = fmt(t.res_deg, 6); status.textContent = ''; renderTilePlot(t); + // 同步:按北京时间整点对齐加载小时雨量(优先从列表选择 ≤ 该整点) + try { await loadRainAligned(z,y,x, t.dt); } catch(e) { /* ignore */ } const idx = gTimes.indexOf(t.dt); if (idx >= 0) { gCurrentIdx = idx; @@ -188,6 +193,8 @@ document.getElementById('tile_res').textContent = fmt(t.res_deg, 6); status.textContent = ''; renderTilePlot(t); + // 同步:按北京时间整点对齐加载小时雨量(优先从列表选择 ≤ 该整点) + try { await loadRainAligned(z,y,x, t.dt); } catch(e) { /* ignore */ } if (gAlias) { try { await loadRealtimeNearest(gAlias, t.dt); } catch(e){} } maybeCalcSector(); maybePlotSquare(); @@ -195,6 +202,209 @@ maybeCalcTileRegionStats(); } + // ---- 小时雨量(CMPA)渲染 ---- + async function loadLatestRainTile(z, y, x){ + const status = document.getElementById('rain_tile_status'); + const res = await fetch(`/api/rain/latest?z=${z}&y=${y}&x=${x}`); + if(!res.ok){ status.textContent='未找到雨量瓦片'; return; } + const t = await res.json(); + fillRainMetaAndPlot(t); + } + + function floorHourStr(dtStr){ + if(!dtStr) return ''; + const m = dtStr.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}):\d{2}:\d{2}$/); + if(!m) return dtStr; + return `${m[1]} ${m[2]}:00:00`; + } + + + async function loadNearestRainTile(z, y, x, dtStr){ + const status = document.getElementById('rain_tile_status'); + if(!dtStr){ return loadLatestRainTile(z,y,x); } + // 将查询时间按北京时间整点对齐,提高匹配准确性 + const base = floorHourStr(dtStr); + let url = `/api/rain/nearest?prefer=lte&tolerance_min=120&z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(base)}`; + let res = await fetch(url); + if(!res.ok){ + // 二次尝试:放宽容差到 24 小时 + url = `/api/rain/nearest?prefer=lte&tolerance_min=1440&z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(base)}`; + res = await fetch(url); + } + if(!res.ok){ + // 优雅降级:若有时间列表,下拉默认第一项 + const sel = document.getElementById('rainTimeSelect'); + if (sel && sel.options.length > 1) { + const first = sel.options[1].value; + sel.value = first; + await loadRainAt(z,y,x, first); + return; + } + status.textContent='未找到匹配的雨量瓦片'; + return; + } + const t = await res.json(); + fillRainMetaAndPlot(t); + } + + function compareDTStr(a, b){ + // a,b 格式 "YYYY-MM-DD HH:MM:SS",直接字符串比较即可 + return a === b ? 0 : (a < b ? -1 : 1); + } + + async function loadRainAligned(z, y, x, dtStr){ + const sel = document.getElementById('rainTimeSelect'); + const status = document.getElementById('rain_tile_status'); + if(!dtStr){ return loadLatestRainTile(z,y,x); } + const base = floorHourStr(dtStr); + // 优先从下拉(倒序)中选择第一个 <= base 的时次 + if (sel && sel.options.length > 1){ + for (let i=1; i 1){ const first = sel.options[1].value; sel.value = first; await loadRainAt(z,y,x, first); return; } + status.textContent='未找到匹配的雨量瓦片'; + } + + function fillRainMetaAndPlot(t){ + const fmt=(n,d=5)=> Number(n).toFixed(d); + document.getElementById('rain_tile_dt').textContent = t.dt || ''; + document.getElementById('rain_tile_z').textContent = t.z ?? ''; + document.getElementById('rain_tile_y').textContent = t.y ?? ''; + document.getElementById('rain_tile_x').textContent = t.x ?? ''; + document.getElementById('rain_tile_w').textContent = fmt(t.west); + document.getElementById('rain_tile_s').textContent = fmt(t.south); + document.getElementById('rain_tile_e').textContent = fmt(t.east); + document.getElementById('rain_tile_n').textContent = fmt(t.north); + document.getElementById('rain_tile_res').textContent = fmt(t.res_deg, 6); + const status = document.getElementById('rain_tile_status'); + if (status) status.textContent=''; + gRainDT = t.dt || ''; + // 同步下拉:若该 dt 存在于列表,选中它 + try{ + const sel = document.getElementById('rainTimeSelect'); + if (sel && gRainDT) { + for (let i=0;i=100(饱和) + ]; + // 非零阈值分割(mm):用于映射到上面颜色段(索引需 +1) + const edges = [0,5,7.5,10,12.5,15,17.5,20,25,30,40,50,75,100, Infinity]; + + // 量化到颜色段,同时保留原值用于 hover + const zBins = []; const custom = []; + for(let r=0;r=edges[nz] && mmrain=%{customdata[2]:.1f} mm' + }]; + const el=document.getElementById('rain_tile_plot'); + const layout={ autosize:true, margin:{l:40,r:8,t:8,b:90}, + xaxis:{title:{text:'经度', standoff: 12}, tickformat:'.2f', constrain:'domain', automargin:true}, + yaxis:{title:{text:'纬度', standoff: 12}, tickformat:'.2f', showticklabels:true, scaleanchor:'x', scaleratio:1, constrain:'domain', automargin:true} + }; + Plotly.newPlot(el, data, layout, {responsive:true, displayModeBar:false}).then(()=>{ + const s = el.clientWidth; Plotly.relayout(el,{height:s}); + }); + window.addEventListener('resize', ()=>{ const s=el.clientWidth; Plotly.relayout(el,{height:s}); Plotly.Plots.resize(el); }); + } + + async function populateRainTimes(z, y, x, fromStr, toStr){ + try{ + let url = `/api/rain/times?z=${z}&y=${y}&x=${x}`; + if (fromStr && toStr) { url += `&from=${encodeURIComponent(fromStr)}&to=${encodeURIComponent(toStr)}`; } + else { url += '&limit=60'; } + const res = await fetch(url); + if(!res.ok) return; + const j = await res.json(); + const sel = document.getElementById('rainTimeSelect'); + if (!sel) return; + while (sel.options.length > 1) sel.remove(1); + const times = j.times || []; + times.forEach(dt=>{ const opt=document.createElement('option'); opt.value=dt; opt.textContent=dt; sel.appendChild(opt); }); + // 若当前已选中的 gRainDT 在列表里,则保持选中 + if (gRainDT){ for(let i=0;i String(n).padStart(2,'0'); return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`; @@ -281,7 +491,7 @@ for (let r = 0; r < h; r++) ys[r] = south + (r + 0.5) * resDeg; // 保存到全局供后续计算 gTileValues = t.values; gXs = xs; gYs = ys; - try { gTileDT = new Date((t.dt || '').replace(/-/g,'/')); } catch {} + try { gTileDT = new Date((t.dt || '').replace(/-/g,'/')); gTileDTStr = t.dt || ''; } catch { gTileDTStr = t.dt || ''; } const colors = ["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"]; // 构建离散色阶(0..14)+ customdata(用于 hover 展示 dBZ) const zBins = []; const custom = []; @@ -569,7 +779,10 @@ const from = fromDTLocalInput(document.getElementById('tsStart').value); const to = fromDTLocalInput(document.getElementById('tsEnd').value); await populateTimes(gZ,gY,gX, from, to); + await populateRainTimes(gZ,gY,gX, from, to); await loadLatestTile(gZ,gY,gX); + // 同步:加载最新小时雨量(若 radar 未返回 dt,则先尝试最新) + try { await loadLatestRainTile(gZ,gY,gX); } catch(e) { /* ignore */ } } }); @@ -579,6 +792,7 @@ const from = fromDTLocalInput(document.getElementById('tsStart').value); const to = fromDTLocalInput(document.getElementById('tsEnd').value); await populateTimes(gZ,gY,gX, from, to); + await populateRainTimes(gZ,gY,gX, from, to); if (gCurrentIdx >= 0) await loadTileAt(gZ,gY,gX, gTimes[gCurrentIdx]); }); @@ -608,6 +822,19 @@ } }); + const rainTimeSelect = document.getElementById('rainTimeSelect'); + if (rainTimeSelect) rainTimeSelect.addEventListener('change', async (e)=>{ + if (!(gZ && gY && gX)) return; + const dt = e.target.value; + if (!dt) { + // 自动匹配到当前雷达时次(就近<=) + const target = (gCurrentIdx>=0 && gTimes[gCurrentIdx]) ? gTimes[gCurrentIdx] : (gTileDTStr || ''); + await loadNearestRainTile(gZ,gY,gX, target); + } else { + await loadRainAt(gZ,gY,gX, dt); + } + }); + const btnPrev = document.getElementById('btnPrev'); if (btnPrev) btnPrev.addEventListener('click', async ()=>{ if (!(gZ && gY && gX)) return; @@ -715,7 +942,24 @@
- +
+
一小时降雨瓦片
+
+ 选择雨量时次: + +
+
+
时间:
+
索引:z= / y= / x=
+
边界:W=,S=,E=,N=
+
分辨率(度/像素):
+
+
+
+
+
正方形裁减区域