feat: 新增雨量瓦片的下载
This commit is contained in:
parent
9aaff59042
commit
28ce15ce13
@ -11,6 +11,7 @@ import (
|
|||||||
"weatherstation/internal/database"
|
"weatherstation/internal/database"
|
||||||
"weatherstation/internal/forecast"
|
"weatherstation/internal/forecast"
|
||||||
"weatherstation/internal/radar"
|
"weatherstation/internal/radar"
|
||||||
|
"weatherstation/internal/rain"
|
||||||
"weatherstation/internal/selftest"
|
"weatherstation/internal/selftest"
|
||||||
"weatherstation/internal/server"
|
"weatherstation/internal/server"
|
||||||
"weatherstation/internal/tools"
|
"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 {
|
if *webOnly {
|
||||||
// 只启动Web服务器 + 导出器
|
// 只启动Web服务器 + 导出器
|
||||||
startExporterBackground(nil)
|
startExporterBackground(nil)
|
||||||
startRadarSchedulerBackground(nil)
|
startRadarSchedulerBackground(nil)
|
||||||
|
startRainSchedulerBackground(nil)
|
||||||
log.Println("启动Web服务器模式...")
|
log.Println("启动Web服务器模式...")
|
||||||
if err := server.StartGinServer(); err != nil {
|
if err := server.StartGinServer(); err != nil {
|
||||||
log.Fatalf("启动Web服务器失败: %v", err)
|
log.Fatalf("启动Web服务器失败: %v", err)
|
||||||
@ -267,6 +285,7 @@ func main() {
|
|||||||
// 只启动UDP服务器 + 导出器
|
// 只启动UDP服务器 + 导出器
|
||||||
startExporterBackground(nil)
|
startExporterBackground(nil)
|
||||||
startRadarSchedulerBackground(nil)
|
startRadarSchedulerBackground(nil)
|
||||||
|
startRainSchedulerBackground(nil)
|
||||||
log.Println("启动UDP服务器模式...")
|
log.Println("启动UDP服务器模式...")
|
||||||
if err := server.StartUDPServer(); err != nil {
|
if err := server.StartUDPServer(); err != nil {
|
||||||
log.Fatalf("启动UDP服务器失败: %v", err)
|
log.Fatalf("启动UDP服务器失败: %v", err)
|
||||||
@ -298,6 +317,7 @@ func main() {
|
|||||||
|
|
||||||
startExporterBackground(&wg)
|
startExporterBackground(&wg)
|
||||||
startRadarSchedulerBackground(&wg)
|
startRadarSchedulerBackground(&wg)
|
||||||
|
startRainSchedulerBackground(&wg)
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
internal/database/rain_tiles.go
Normal file
77
internal/database/rain_tiles.go
Normal 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
231
internal/rain/scheduler.go
Normal 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
70
internal/rain/store.go
Normal 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)
|
||||||
|
}
|
||||||
@ -51,6 +51,11 @@ func StartGinServer() error {
|
|||||||
api.GET("/radar/weather_aliases", radarWeatherAliasesHandler)
|
api.GET("/radar/weather_aliases", radarWeatherAliasesHandler)
|
||||||
api.GET("/radar/aliases", radarConfigAliasesHandler)
|
api.GET("/radar/aliases", radarConfigAliasesHandler)
|
||||||
api.GET("/radar/weather_nearest", radarWeatherNearestHandler)
|
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端口
|
// 获取配置的Web端口
|
||||||
|
|||||||
@ -121,8 +121,12 @@ func latestRadarTileHandler(c *gin.Context) {
|
|||||||
vals[row] = rowVals
|
vals[row] = rowVals
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if loc == nil {
|
||||||
|
loc = time.FixedZone("CST", 8*3600)
|
||||||
|
}
|
||||||
resp := radarTileResponse{
|
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,
|
Z: rec.Z,
|
||||||
Y: rec.Y,
|
Y: rec.Y,
|
||||||
X: rec.X,
|
X: rec.X,
|
||||||
@ -189,8 +193,9 @@ func radarTileAtHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
vals[row] = rowVals
|
vals[row] = rowVals
|
||||||
}
|
}
|
||||||
|
// 统一以 CST(+8) 输出
|
||||||
resp := radarTileResponse{
|
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,
|
Z: rec.Z,
|
||||||
Y: rec.Y,
|
Y: rec.Y,
|
||||||
X: rec.X,
|
X: rec.X,
|
||||||
@ -251,7 +256,7 @@ func radarTileTimesHandler(c *gin.Context) {
|
|||||||
if err := rows.Scan(&dt); err != nil {
|
if err := rows.Scan(&dt); err != nil {
|
||||||
continue
|
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})
|
c.JSON(http.StatusOK, gin.H{"times": times})
|
||||||
}
|
}
|
||||||
@ -337,8 +342,9 @@ func nearestRadarTileHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
vals[rowi] = rowVals
|
vals[rowi] = rowVals
|
||||||
}
|
}
|
||||||
|
// 统一以 CST(+8) 输出
|
||||||
resp := radarTileResponse{
|
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,
|
Z: rec.Z,
|
||||||
Y: rec.Y,
|
Y: rec.Y,
|
||||||
X: rec.X,
|
X: rec.X,
|
||||||
|
|||||||
311
internal/server/rain_api.go
Normal file
311
internal/server/rain_api.go
Normal 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"` // 单位: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
|
||||||
|
}
|
||||||
@ -19,10 +19,13 @@
|
|||||||
let gTimes = [];
|
let gTimes = [];
|
||||||
let gCurrentIdx = -1;
|
let gCurrentIdx = -1;
|
||||||
let gAlias = '';
|
let gAlias = '';
|
||||||
|
// 雨量瓦片(小时累计)当前展示的时间,便于状态同步
|
||||||
|
let gRainDT = '';
|
||||||
// 3H 预报相关全局量
|
// 3H 预报相关全局量
|
||||||
let gTileValues = null, gXs = null, gYs = null;
|
let gTileValues = null, gXs = null, gYs = null;
|
||||||
let gWindFromDeg = null, gWindSpeedMS = null;
|
let gWindFromDeg = null, gWindSpeedMS = null;
|
||||||
let gTileDT = null;
|
let gTileDT = null;
|
||||||
|
let gTileDTStr = '';
|
||||||
let gStLat = null, gStLon = null;
|
let gStLat = null, gStLon = null;
|
||||||
|
|
||||||
async function loadStations() {
|
async function loadStations() {
|
||||||
@ -149,6 +152,8 @@
|
|||||||
document.getElementById('tile_res').textContent = fmt(t.res_deg, 6);
|
document.getElementById('tile_res').textContent = fmt(t.res_deg, 6);
|
||||||
status.textContent = '';
|
status.textContent = '';
|
||||||
renderTilePlot(t);
|
renderTilePlot(t);
|
||||||
|
// 同步:按北京时间整点对齐加载小时雨量(优先从列表选择 ≤ 该整点)
|
||||||
|
try { await loadRainAligned(z,y,x, t.dt); } catch(e) { /* ignore */ }
|
||||||
const idx = gTimes.indexOf(t.dt);
|
const idx = gTimes.indexOf(t.dt);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
gCurrentIdx = idx;
|
gCurrentIdx = idx;
|
||||||
@ -188,6 +193,8 @@
|
|||||||
document.getElementById('tile_res').textContent = fmt(t.res_deg, 6);
|
document.getElementById('tile_res').textContent = fmt(t.res_deg, 6);
|
||||||
status.textContent = '';
|
status.textContent = '';
|
||||||
renderTilePlot(t);
|
renderTilePlot(t);
|
||||||
|
// 同步:按北京时间整点对齐加载小时雨量(优先从列表选择 ≤ 该整点)
|
||||||
|
try { await loadRainAligned(z,y,x, t.dt); } catch(e) { /* ignore */ }
|
||||||
if (gAlias) { try { await loadRealtimeNearest(gAlias, t.dt); } catch(e){} }
|
if (gAlias) { try { await loadRealtimeNearest(gAlias, t.dt); } catch(e){} }
|
||||||
maybeCalcSector();
|
maybeCalcSector();
|
||||||
maybePlotSquare();
|
maybePlotSquare();
|
||||||
@ -195,6 +202,209 @@
|
|||||||
maybeCalcTileRegionStats();
|
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)', // 5–7.5
|
||||||
|
'rgba(97, 169, 97, 0.78)', // 7.5–10
|
||||||
|
'rgba(81, 148, 76, 0.78)', // 10–12.5
|
||||||
|
'rgba(90, 158, 112, 0.78)', // 12.5–15
|
||||||
|
'rgba(143, 194, 254, 0.78)', // 15–17.5
|
||||||
|
'rgba(92, 134, 245, 0.78)', // 17.5–20
|
||||||
|
'rgba(66, 87, 240, 0.78)', // 20–25
|
||||||
|
'rgba(45, 48, 214, 0.78)', // 25–30
|
||||||
|
'rgba(26, 15, 166, 0.78)', // 30–40
|
||||||
|
'rgba(63, 22, 145, 0.78)', // 40–50
|
||||||
|
'rgba(191, 70, 148, 0.78)', // 50–75
|
||||||
|
'rgba(213, 1, 146, 0.78)', // 75–100
|
||||||
|
'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){
|
function fmtDTLocal(dt){
|
||||||
const pad = (n)=> String(n).padStart(2,'0');
|
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())}`;
|
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;
|
for (let r = 0; r < h; r++) ys[r] = south + (r + 0.5) * resDeg;
|
||||||
// 保存到全局供后续计算
|
// 保存到全局供后续计算
|
||||||
gTileValues = t.values; gXs = xs; gYs = ys;
|
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"];
|
const colors = ["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
|
||||||
// 构建离散色阶(0..14)+ customdata(用于 hover 展示 dBZ)
|
// 构建离散色阶(0..14)+ customdata(用于 hover 展示 dBZ)
|
||||||
const zBins = []; const custom = [];
|
const zBins = []; const custom = [];
|
||||||
@ -569,7 +779,10 @@
|
|||||||
const from = fromDTLocalInput(document.getElementById('tsStart').value);
|
const from = fromDTLocalInput(document.getElementById('tsStart').value);
|
||||||
const to = fromDTLocalInput(document.getElementById('tsEnd').value);
|
const to = fromDTLocalInput(document.getElementById('tsEnd').value);
|
||||||
await populateTimes(gZ,gY,gX, from, to);
|
await populateTimes(gZ,gY,gX, from, to);
|
||||||
|
await populateRainTimes(gZ,gY,gX, from, to);
|
||||||
await loadLatestTile(gZ,gY,gX);
|
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 from = fromDTLocalInput(document.getElementById('tsStart').value);
|
||||||
const to = fromDTLocalInput(document.getElementById('tsEnd').value);
|
const to = fromDTLocalInput(document.getElementById('tsEnd').value);
|
||||||
await populateTimes(gZ,gY,gX, from, to);
|
await populateTimes(gZ,gY,gX, from, to);
|
||||||
|
await populateRainTimes(gZ,gY,gX, from, to);
|
||||||
if (gCurrentIdx >= 0) await loadTileAt(gZ,gY,gX, gTimes[gCurrentIdx]);
|
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');
|
const btnPrev = document.getElementById('btnPrev');
|
||||||
if (btnPrev) btnPrev.addEventListener('click', async ()=>{
|
if (btnPrev) btnPrev.addEventListener('click', async ()=>{
|
||||||
if (!(gZ && gY && gX)) return;
|
if (!(gZ && gY && gX)) return;
|
||||||
@ -715,7 +942,24 @@
|
|||||||
<div id="tile_plot" class="plot-box-sm mt-3"></div>
|
<div id="tile_plot" class="plot-box-sm mt-3"></div>
|
||||||
</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%;">
|
<div class="card mt-4" style="width:100%;">
|
||||||
<div class="text-lg font-semibold mb-2">正方形裁减区域</div>
|
<div class="text-lg font-semibold mb-2">正方形裁减区域</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user