feat: 新增雨量瓦片的下载

This commit is contained in:
yarnom 2025-10-07 12:31:50 +08:00
parent 9aaff59042
commit 28ce15ce13
8 changed files with 970 additions and 6 deletions

View File

@ -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()
}
}

View File

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

231
internal/rain/scheduler.go Normal file
View File

@ -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) }

70
internal/rain/store.go Normal file
View File

@ -0,0 +1,70 @@
package rain
import (
"context"
"fmt"
"path"
"regexp"
"strconv"
"strings"
"time"
"weatherstation/internal/database"
)
var (
// Matches .../<PRODUCT>/<YYYYMMDD>/<HH>/<mm>/<z>/<y>/<x>.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)
}

View File

@ -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端口

View File

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

311
internal/server/rain_api.go Normal file
View File

@ -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"` // 单位mmnull 表示无效
}
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
}

View File

@ -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<sel.options.length; i++){
const v = sel.options[i].value;
if (compareDTStr(v, base) <= 0){ sel.value = v; await loadRainAt(z,y,x, v); return; }
}
// 未命中则继续兜底
}
// 兜底:尝试 exact at(base),失败再 lte 24h再 latest
let res = await fetch(`/api/rain/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(base)}`);
if (res.ok){ const t = await res.json(); fillRainMetaAndPlot(t); return; }
res = await fetch(`/api/rain/nearest?prefer=lte&tolerance_min=1440&z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(base)}`);
if (res.ok){ const t = await res.json(); fillRainMetaAndPlot(t); return; }
if (sel && sel.options.length > 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<sel.options.length;i++){
if (sel.options[i].value === gRainDT){ sel.value = gRainDT; break; }
}
}
} catch {}
renderRainTilePlot(t);
}
function renderRainTilePlot(t){
if(!t || !t.values) return;
const w=t.width, h=t.height, resDeg=t.res_deg;
const west=t.west, south=t.south;
const xs=new Array(w); for(let c=0;c<w;c++) xs[c] = west + (c+0.5)*resDeg;
const ys=new Array(h); for(let r=0;r<h;r++) ys[r] = south + (r+0.5)*resDeg;
// 色带按照给定定义,增加 index=0 作为 0mm 的纯白
// 之后 14 段与示例保持一致
const bandColors = [
'rgba(255, 255, 255, 1.0)', // 0 mm 专用(白色)
'rgba(126, 212, 121, 0.78)', // (0,5)
'rgba(110, 200, 109, 0.78)', // 57.5
'rgba(97, 169, 97, 0.78)', // 7.510
'rgba(81, 148, 76, 0.78)', // 1012.5
'rgba(90, 158, 112, 0.78)', // 12.515
'rgba(143, 194, 254, 0.78)', // 1517.5
'rgba(92, 134, 245, 0.78)', // 17.520
'rgba(66, 87, 240, 0.78)', // 2025
'rgba(45, 48, 214, 0.78)', // 2530
'rgba(26, 15, 166, 0.78)', // 3040
'rgba(63, 22, 145, 0.78)', // 4050
'rgba(191, 70, 148, 0.78)', // 5075
'rgba(213, 1, 146, 0.78)', // 75100
'rgba(213, 1, 146, 0.78)' // >=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<h;r++){
const row = t.values[r] || [];
const rowBins = []; const rowCustom = [];
for(let c=0;c<w;c++){
const v = row[c];
if(v==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
let mm = Number(v); if(mm<0) mm=0;
let idx = 0;
if (mm === 0) {
idx = 0; // 白色 0mm
} else {
let nz = 0; while(nz < edges.length-1 && !(mm>=edges[nz] && mm<edges[nz+1])) nz++;
idx = Math.min(nz + 1, bandColors.length - 1);
}
rowBins.push(idx);
rowCustom.push([r,c,mm]);
}
zBins.push(rowBins); custom.push(rowCustom);
}
// 将 14 段映射为均匀 positions 的 colorscale
const colorscale = [];
for(let i=0;i<bandColors.length;i++){
const tpos = bandColors.length===1 ? 0 : i/(bandColors.length-1);
colorscale.push([tpos, bandColors[i]]);
}
const data=[{
type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom,
colorscale: colorscale, zmin:0, zmax:bandColors.length-1,
colorbar:{
orientation:'h', x:0.5, y:-0.12, xanchor:'center', yanchor:'top',
len:0.8, thickness:16, title:{text:'mm', side:'bottom'},
// 对应 5/10/15/20/30/50/100 的分段索引:在加入 0mm 白色后需整体 +1
tickmode:'array', tickvals:[1,3,5,7,9,11,13], ticktext:['5','10','15','20','30','50','100']
},
hovertemplate: 'lon=%{x:.3f}, lat=%{y:.3f}<br>rain=%{customdata[2]:.1f} mm<extra></extra>'
}];
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<sel.options.length;i++){ if(sel.options[i].value===gRainDT){ sel.value=gRainDT; break; } } }
} catch {}
}
async function loadRainAt(z, y, x, dtStr){
const status = document.getElementById('rain_tile_status');
if(!dtStr){ return loadNearestRainTile(z,y,x, (gTileDTStr || '')); }
const res = await fetch(`/api/rain/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}`);
if(!res.ok){ status.textContent='未找到雨量瓦片'; return; }
const t = await res.json();
fillRainMetaAndPlot(t);
}
function fmtDTLocal(dt){
const pad = (n)=> 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,6 +942,23 @@
<div id="tile_plot" class="plot-box-sm mt-3"></div>
</div>
<div class="card mt-4">
<div class="text-base font-semibold mb-2">一小时降雨瓦片</div>
<div class="flex flex-wrap items-center gap-2 mb-2">
<span class="text-sm text-gray-700">选择雨量时次:</span>
<select id="rainTimeSelect" class="border rounded px-2 py-1 text-sm min-w-[240px]">
<option value="">自动匹配(就近≤)</option>
</select>
</div>
<div class="text-sm space-y-1">
<div>时间:<span id="rain_tile_dt" class="font-mono"></span></div>
<div>索引z=<span id="rain_tile_z"></span> / y=<span id="rain_tile_y"></span> / x=<span id="rain_tile_x"></span></div>
<div>边界W=<span id="rain_tile_w"></span>S=<span id="rain_tile_s"></span>E=<span id="rain_tile_e"></span>N=<span id="rain_tile_n"></span></div>
<div>分辨率(度/像素):<span id="rain_tile_res"></span></div>
<div id="rain_tile_status" class="text-gray-500"></div>
</div>
<div id="rain_tile_plot" class="plot-box-sm mt-3"></div>
</div>
<div class="card mt-4" style="width:100%;">