feat: 新增雨量瓦片的下载
This commit is contained in:
parent
9aaff59042
commit
28ce15ce13
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
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/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端口
|
||||
|
||||
@ -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
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 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)', // 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){
|
||||
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,7 +942,24 @@
|
||||
<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%;">
|
||||
<div class="text-lg font-semibold mb-2">正方形裁减区域</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user