Compare commits
6 Commits
317e12900a
...
11e5c73275
| Author | SHA1 | Date | |
|---|---|---|---|
| 11e5c73275 | |||
| da67660fe7 | |||
| a03c60469f | |||
| 2c7f9a0f47 | |||
| cfb0bca723 | |||
| 0da2c838c2 |
@ -1,47 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"time"
|
|
||||||
"weatherstation/internal/radarfetch"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var outRoot string
|
|
||||||
var interval time.Duration
|
|
||||||
var tz int
|
|
||||||
var z, y, x int
|
|
||||||
var chinaURL, huananURL, nanningURL, cmaBase string
|
|
||||||
|
|
||||||
flag.StringVar(&outRoot, "out-root", "./radar_data", "output root directory for radar data")
|
|
||||||
flag.DurationVar(&interval, "interval", 10*time.Minute, "download interval")
|
|
||||||
flag.IntVar(&tz, "tz-offset", 8, "local tz offset to UTC (hours)")
|
|
||||||
flag.IntVar(&z, "z", 7, "tile z")
|
|
||||||
flag.IntVar(&y, "y", 40, "tile y")
|
|
||||||
flag.IntVar(&x, "x", 102, "tile x")
|
|
||||||
flag.StringVar(&chinaURL, "nmc-china-url", "https://www.nmc.cn/publish/radar/chinaall.html", "NMC China page URL")
|
|
||||||
flag.StringVar(&huananURL, "nmc-huanan-url", "https://www.nmc.cn/publish/radar/huanan.html", "NMC Huanan page URL")
|
|
||||||
flag.StringVar(&nanningURL, "nmc-nanning-url", "https://www.nmc.cn/publish/radar/guang-xi/nan-ning.htm", "NMC Nanning page URL")
|
|
||||||
flag.StringVar(&cmaBase, "cma-base", "https://image.data.cma.cn", "CMA base URL")
|
|
||||||
var once bool
|
|
||||||
flag.BoolVar(&once, "once", false, "run a single cycle and exit")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
opts := radarfetch.Options{
|
|
||||||
OutRoot: outRoot,
|
|
||||||
TZOffset: tz,
|
|
||||||
Interval: interval,
|
|
||||||
NMCChinaURL: chinaURL,
|
|
||||||
NMCHuananURL: huananURL,
|
|
||||||
NMCNanningURL: nanningURL,
|
|
||||||
CMABase: cmaBase,
|
|
||||||
Z: z,
|
|
||||||
Y: y,
|
|
||||||
X: x,
|
|
||||||
}
|
|
||||||
if once {
|
|
||||||
_ = radarfetch.RunOnce(opts)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
radarfetch.Run(opts)
|
|
||||||
}
|
|
||||||
@ -10,7 +10,7 @@ import (
|
|||||||
"weatherstation/internal/config"
|
"weatherstation/internal/config"
|
||||||
"weatherstation/internal/database"
|
"weatherstation/internal/database"
|
||||||
"weatherstation/internal/forecast"
|
"weatherstation/internal/forecast"
|
||||||
"weatherstation/internal/radarfetch"
|
"weatherstation/internal/radar"
|
||||||
"weatherstation/internal/selftest"
|
"weatherstation/internal/selftest"
|
||||||
"weatherstation/internal/server"
|
"weatherstation/internal/server"
|
||||||
"weatherstation/internal/tools"
|
"weatherstation/internal/tools"
|
||||||
@ -45,6 +45,10 @@ func main() {
|
|||||||
var exportRangeOnly = flag.Bool("export_range", false, "按日期范围导出10分钟CSV(含ZTD融合),并退出。日期格式支持 YYYY-MM-DD 或 YYYYMMDD")
|
var exportRangeOnly = flag.Bool("export_range", false, "按日期范围导出10分钟CSV(含ZTD融合),并退出。日期格式支持 YYYY-MM-DD 或 YYYYMMDD")
|
||||||
var exportStart = flag.String("export_start", "", "导出起始日期(含),格式 YYYY-MM-DD 或 YYYYMMDD")
|
var exportStart = flag.String("export_start", "", "导出起始日期(含),格式 YYYY-MM-DD 或 YYYYMMDD")
|
||||||
var exportEnd = flag.String("export_end", "", "导出结束日期(含),格式 YYYY-MM-DD 或 YYYYMMDD")
|
var exportEnd = flag.String("export_end", "", "导出结束日期(含),格式 YYYY-MM-DD 或 YYYYMMDD")
|
||||||
|
// 雷达:导入单个CMA瓦片到数据库
|
||||||
|
var importTile = flag.Bool("import_tile", false, "导入一个CMA雷达瓦片到数据库并退出")
|
||||||
|
var tileURL = flag.String("tile_url", "", "瓦片URL或/tiles/...路径,用于解析product/时间/z/y/x")
|
||||||
|
var tilePath = flag.String("tile_path", "", "瓦片本地文件路径(.bin)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// 设置日志
|
// 设置日志
|
||||||
@ -111,6 +115,18 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导入一个CMA雷达瓦片到数据库
|
||||||
|
if *importTile {
|
||||||
|
if *tileURL == "" || *tilePath == "" {
|
||||||
|
log.Fatalln("import_tile 需要提供 --tile_url 与 --tile_path")
|
||||||
|
}
|
||||||
|
if err := radar.ImportTileFile(context.Background(), *tileURL, *tilePath); err != nil {
|
||||||
|
log.Fatalf("导入雷达瓦片失败: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("导入雷达瓦片完成")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 历史CSV范围导出
|
// 历史CSV范围导出
|
||||||
if *exportRangeOnly {
|
if *exportRangeOnly {
|
||||||
if *exportStart == "" || *exportEnd == "" {
|
if *exportStart == "" || *exportEnd == "" {
|
||||||
@ -223,8 +239,7 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动雷达抓取(后台定时)
|
startRadarSchedulerBackground := func(wg *sync.WaitGroup) {
|
||||||
startRadarBackground := func(wg *sync.WaitGroup) {
|
|
||||||
if wg != nil {
|
if wg != nil {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
}
|
}
|
||||||
@ -234,25 +249,16 @@ func main() {
|
|||||||
wg.Done()
|
wg.Done()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
log.Println("启动雷达抓取任务(10分钟)...")
|
log.Println("启动雷达下载任务(每10分钟,无延迟,固定瓦片 7/40/102)...")
|
||||||
opts := radarfetch.Options{
|
ctx := context.Background()
|
||||||
OutRoot: "./radar_data",
|
_ = radar.Start(ctx, radar.Options{StoreToDB: true, Z: 7, Y: 40, X: 102})
|
||||||
TZOffset: 8,
|
|
||||||
Interval: 10 * time.Minute,
|
|
||||||
NMCChinaURL: "https://www.nmc.cn/publish/radar/chinaall.html",
|
|
||||||
NMCHuananURL: "https://www.nmc.cn/publish/radar/huanan.html",
|
|
||||||
NMCNanningURL: "https://www.nmc.cn/publish/radar/guang-xi/nan-ning.htm",
|
|
||||||
CMABase: "https://image.data.cma.cn",
|
|
||||||
Z: 7, Y: 40, X: 102,
|
|
||||||
}
|
|
||||||
radarfetch.Run(opts)
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
if *webOnly {
|
if *webOnly {
|
||||||
// 只启动Web服务器 + 导出器
|
// 只启动Web服务器 + 导出器
|
||||||
startExporterBackground(nil)
|
startExporterBackground(nil)
|
||||||
startRadarBackground(nil)
|
startRadarSchedulerBackground(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)
|
||||||
@ -260,7 +266,7 @@ func main() {
|
|||||||
} else if *udpOnly {
|
} else if *udpOnly {
|
||||||
// 只启动UDP服务器 + 导出器
|
// 只启动UDP服务器 + 导出器
|
||||||
startExporterBackground(nil)
|
startExporterBackground(nil)
|
||||||
startRadarBackground(nil)
|
startRadarSchedulerBackground(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)
|
||||||
@ -291,7 +297,7 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
startExporterBackground(&wg)
|
startExporterBackground(&wg)
|
||||||
startRadarBackground(&wg)
|
startRadarSchedulerBackground(&wg)
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -538,3 +538,29 @@ COMMENT ON TABLE public.forecast_hourly IS '小时级预报表,按issued_at版
|
|||||||
COMMENT ON COLUMN public.forecast_hourly.issued_at IS '预报方案发布时间(版本时间)';
|
COMMENT ON COLUMN public.forecast_hourly.issued_at IS '预报方案发布时间(版本时间)';
|
||||||
COMMENT ON COLUMN public.forecast_hourly.forecast_time IS '目标小时时间戳';
|
COMMENT ON COLUMN public.forecast_hourly.forecast_time IS '目标小时时间戳';
|
||||||
COMMENT ON COLUMN public.forecast_hourly.rain_mm_x1000 IS '该小时降雨量,单位mm×1000';
|
COMMENT ON COLUMN public.forecast_hourly.rain_mm_x1000 IS '该小时降雨量,单位mm×1000';
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: radar_weather; Type: TABLE; Schema: public; Owner: -
|
||||||
|
-- 用途:雷达站实时气象(彩云实时),每10分钟采样一条
|
||||||
|
--
|
||||||
|
CREATE TABLE IF NOT EXISTS public.radar_weather (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
alias TEXT NOT NULL,
|
||||||
|
lat DOUBLE PRECISION NOT NULL,
|
||||||
|
lon DOUBLE PRECISION NOT NULL,
|
||||||
|
dt TIMESTAMPTZ NOT NULL,
|
||||||
|
temperature DOUBLE PRECISION,
|
||||||
|
humidity DOUBLE PRECISION,
|
||||||
|
cloudrate DOUBLE PRECISION,
|
||||||
|
visibility DOUBLE PRECISION,
|
||||||
|
dswrf DOUBLE PRECISION,
|
||||||
|
wind_speed DOUBLE PRECISION,
|
||||||
|
wind_direction DOUBLE PRECISION,
|
||||||
|
pressure DOUBLE PRECISION,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 约束与索引
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS radar_weather_udx ON public.radar_weather(alias, dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_radar_weather_dt ON public.radar_weather(dt);
|
||||||
|
COMMENT ON TABLE public.radar_weather IS '雷达站实时气象数据表(彩云Realtime),按10分钟存档';
|
||||||
|
|||||||
63
internal/database/radar_tiles.go
Normal file
63
internal/database/radar_tiles.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpsertRadarTile stores a radar tile into table `radar_tiles`.
|
||||||
|
// Assumes the table exists with schema compatible to columns used below.
|
||||||
|
func UpsertRadarTile(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 radar_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 radar tile (%s %s z=%d y=%d x=%d): %w", product, dt.Format(time.RFC3339), z, y, x, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
70
internal/database/radar_weather.go
Normal file
70
internal/database/radar_weather.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpsertRadarWeather stores a realtime snapshot for a radar station.
|
||||||
|
// Table schema (expected):
|
||||||
|
//
|
||||||
|
// CREATE TABLE IF NOT EXISTS radar_weather (
|
||||||
|
// id SERIAL PRIMARY KEY,
|
||||||
|
// alias TEXT NOT NULL,
|
||||||
|
// lat DOUBLE PRECISION NOT NULL,
|
||||||
|
// lon DOUBLE PRECISION NOT NULL,
|
||||||
|
// dt TIMESTAMPTZ NOT NULL,
|
||||||
|
// temperature DOUBLE PRECISION,
|
||||||
|
// humidity DOUBLE PRECISION,
|
||||||
|
// cloudrate DOUBLE PRECISION,
|
||||||
|
// visibility DOUBLE PRECISION,
|
||||||
|
// dswrf DOUBLE PRECISION,
|
||||||
|
// wind_speed DOUBLE PRECISION,
|
||||||
|
// wind_direction DOUBLE PRECISION,
|
||||||
|
// pressure DOUBLE PRECISION,
|
||||||
|
// created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
// );
|
||||||
|
// CREATE UNIQUE INDEX IF NOT EXISTS radar_weather_udx ON radar_weather(alias, dt);
|
||||||
|
func UpsertRadarWeather(
|
||||||
|
ctx context.Context,
|
||||||
|
db *sql.DB,
|
||||||
|
alias string,
|
||||||
|
lat, lon float64,
|
||||||
|
dt time.Time,
|
||||||
|
temperature, humidity, cloudrate, visibility, dswrf, windSpeed, windDir, pressure float64,
|
||||||
|
) error {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO radar_weather (
|
||||||
|
alias, lat, lon, dt,
|
||||||
|
temperature, humidity, cloudrate, visibility, dswrf,
|
||||||
|
wind_speed, wind_direction, pressure
|
||||||
|
) VALUES (
|
||||||
|
$1,$2,$3,$4,
|
||||||
|
$5,$6,$7,$8,$9,
|
||||||
|
$10,$11,$12
|
||||||
|
)
|
||||||
|
ON CONFLICT (alias, dt)
|
||||||
|
DO UPDATE SET
|
||||||
|
lat = EXCLUDED.lat,
|
||||||
|
lon = EXCLUDED.lon,
|
||||||
|
temperature = EXCLUDED.temperature,
|
||||||
|
humidity = EXCLUDED.humidity,
|
||||||
|
cloudrate = EXCLUDED.cloudrate,
|
||||||
|
visibility = EXCLUDED.visibility,
|
||||||
|
dswrf = EXCLUDED.dswrf,
|
||||||
|
wind_speed = EXCLUDED.wind_speed,
|
||||||
|
wind_direction = EXCLUDED.wind_direction,
|
||||||
|
pressure = EXCLUDED.pressure`
|
||||||
|
|
||||||
|
_, err := db.ExecContext(ctx, q,
|
||||||
|
alias, lat, lon, dt,
|
||||||
|
temperature, humidity, cloudrate, visibility, dswrf,
|
||||||
|
windSpeed, windDir, pressure,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert radar_weather (%s %s): %w", alias, dt.Format(time.RFC3339), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
508
internal/radar/scheduler.go
Normal file
508
internal/radar/scheduler.go
Normal file
@ -0,0 +1,508 @@
|
|||||||
|
package radar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
neturl "net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"weatherstation/internal/config"
|
||||||
|
"weatherstation/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options controls the radar scheduler behavior.
|
||||||
|
type Options struct {
|
||||||
|
Enable bool
|
||||||
|
OutputDir string
|
||||||
|
Delay time.Duration // time after each 6-minute boundary to trigger download
|
||||||
|
BaseURL string // optional: where to download from (template-based)
|
||||||
|
MaxRetries int
|
||||||
|
// Tile indices (4326 pyramid). Defaults: z=7,y=40,x=102 (Nanning region example)
|
||||||
|
Z int
|
||||||
|
Y int
|
||||||
|
X int
|
||||||
|
// StoreToDB controls whether to store fetched tiles into PostgreSQL `radar_tiles`.
|
||||||
|
StoreToDB bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the radar download scheduler. It reads options from env if zero value provided.
|
||||||
|
// Env vars:
|
||||||
|
//
|
||||||
|
// RADAR_ENABLED=true|false (default: true)
|
||||||
|
// RADAR_DIR=radar_data (default)
|
||||||
|
// RADAR_DELAY_SEC=120 (2 minutes; trigger after each boundary)
|
||||||
|
// RADAR_MAX_RETRIES=2
|
||||||
|
// RADAR_BASE_URL=<template URL, optional>
|
||||||
|
func Start(ctx context.Context, opts Options) error {
|
||||||
|
if !opts.Enable && !envEnabledDefaultTrue() {
|
||||||
|
log.Println("[radar] scheduler disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.OutputDir == "" {
|
||||||
|
opts.OutputDir = getenvDefault("RADAR_DIR", "radar_data")
|
||||||
|
}
|
||||||
|
// Delay 不再用于 10 分钟调度流程,这里保留读取但不使用
|
||||||
|
if opts.Delay == 0 {
|
||||||
|
delaySec := getenvIntDefault("RADAR_DELAY_SEC", 0)
|
||||||
|
opts.Delay = time.Duration(delaySec) * time.Second
|
||||||
|
}
|
||||||
|
if opts.MaxRetries == 0 {
|
||||||
|
opts.MaxRetries = getenvIntDefault("RADAR_MAX_RETRIES", 2)
|
||||||
|
}
|
||||||
|
if opts.BaseURL == "" {
|
||||||
|
// Default to CMA image server tiles
|
||||||
|
// Placeholders: %Y %m %d %H %M {z} {y} {x}
|
||||||
|
opts.BaseURL = getenvDefault("RADAR_BASE_URL", "https://image.data.cma.cn/tiles/China/RADAR_L3_MST_CREF_GISJPG_Tiles_CR/%Y%m%d/%H/%M/{z}/{y}/{x}.bin")
|
||||||
|
}
|
||||||
|
if opts.Z == 0 && opts.Y == 0 && opts.X == 0 {
|
||||||
|
// Default tile requested
|
||||||
|
opts.Z, opts.Y, opts.X = getenvIntDefault("RADAR_Z", 7), getenvIntDefault("RADAR_Y", 40), getenvIntDefault("RADAR_X", 102)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create radar output dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if loc == nil {
|
||||||
|
loc = time.FixedZone("CST", 8*3600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先立即执行一次(不延迟),随后每10分钟执行
|
||||||
|
go func() {
|
||||||
|
if err := runOnceFromNMC(ctx, opts); err != nil {
|
||||||
|
log.Printf("[radar] first run error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// 改为10分钟固定轮询一次 NMC 接口,解析时间并下载对应CMA瓦片
|
||||||
|
go loop10(ctx, loc, opts)
|
||||||
|
log.Printf("[radar] scheduler started (interval=10m, dir=%s, tile=%d/%d/%d)", opts.OutputDir, opts.Z, opts.Y, opts.X)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loop10(ctx context.Context, loc *time.Location, opts Options) {
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().In(loc)
|
||||||
|
// 对齐到10分钟边界
|
||||||
|
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 := runOnceFromNMC(ctx, opts); err != nil {
|
||||||
|
log.Printf("[radar] runOnce error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryStartupCatchup(ctx context.Context, loc *time.Location, opts Options) {
|
||||||
|
now := time.Now().In(loc)
|
||||||
|
lastSlot := roundDown6(now).Add(-opts.Delay)
|
||||||
|
_ = downloadForSlot(ctx, lastSlot, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundDown6 returns t truncated to the nearest lower multiple of 6 minutes.
|
||||||
|
func roundDown6(t time.Time) time.Time {
|
||||||
|
// Truncate supports arbitrary durations.
|
||||||
|
return t.Truncate(6 * time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundDownN(t time.Time, d time.Duration) time.Time {
|
||||||
|
return t.Truncate(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadForSlot performs the actual download for the given nominal run time.
|
||||||
|
// It constructs a filename like: radar_COMP_20060102_1504.png under opts.OutputDir.
|
||||||
|
// If RADAR_BASE_URL is provided, it's treated as a format string with Go time
|
||||||
|
// layout tokens, e.g. https://example/COMP/%Y/%m/%d/%H%M.png (Go layout applied).
|
||||||
|
func downloadForSlot(ctx context.Context, runAt time.Time, opts Options) error {
|
||||||
|
// Determine the product nominal time: align to boundary (6-minute steps)
|
||||||
|
slot := roundDown6(runAt)
|
||||||
|
fname := fmt.Sprintf("radar_z%d_y%d_x%d_%s.bin", opts.Z, opts.Y, opts.X, slot.Format("20060102_1504"))
|
||||||
|
dest := filepath.Join(opts.OutputDir, fname)
|
||||||
|
|
||||||
|
// If file already exists, skip.
|
||||||
|
if _, err := os.Stat(dest); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.BaseURL == "" {
|
||||||
|
// No remote configured: create a placeholder to prove scheduling works.
|
||||||
|
content := []byte(fmt.Sprintf("placeholder for %s\n", slot.Format(time.RFC3339)))
|
||||||
|
if err := os.WriteFile(dest, content, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write placeholder: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("[radar] wrote placeholder %s", dest)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a possibly strftime-like template to Go layout tokens.
|
||||||
|
url := buildURLFromTemplate(opts.BaseURL, slot, opts.Z, opts.Y, opts.X)
|
||||||
|
// HTTP GET with timeout.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// CMA requires referer/origin headers typically
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// Write to temp then rename.
|
||||||
|
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 {
|
||||||
|
// If cross-device rename fails, fallback to copy
|
||||||
|
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 for copy: %w", rerr)
|
||||||
|
}
|
||||||
|
if werr := os.WriteFile(dest, data, 0o644); werr != nil {
|
||||||
|
return fmt.Errorf("write final: %w", werr)
|
||||||
|
}
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
}
|
||||||
|
log.Printf("[radar] saved %s (url=%s)", dest, url)
|
||||||
|
|
||||||
|
// Optionally store to DB
|
||||||
|
if opts.StoreToDB {
|
||||||
|
// Read the just-written bytes to pass to DB store; alternatively stream earlier
|
||||||
|
b, rerr := os.ReadFile(dest)
|
||||||
|
if rerr != nil {
|
||||||
|
return fmt.Errorf("read saved tile for DB: %w", rerr)
|
||||||
|
}
|
||||||
|
if err := StoreTileBytes(ctx, url, b); err != nil {
|
||||||
|
return fmt.Errorf("store tile to DB: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("[radar] stored to DB: z=%d y=%d x=%d t=%s", opts.Z, opts.Y, opts.X, slot.Format("2006-01-02 15:04"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildURLFromTemplate(tpl string, t time.Time, z, y, x int) string {
|
||||||
|
// Support a minimal subset of strftime tokens to Go layout.
|
||||||
|
repl := map[string]string{
|
||||||
|
"%Y": "2006",
|
||||||
|
"%m": "01",
|
||||||
|
"%d": "02",
|
||||||
|
"%H": "15",
|
||||||
|
"%M": "04",
|
||||||
|
}
|
||||||
|
out := tpl
|
||||||
|
for k, v := range repl {
|
||||||
|
out = strings.ReplaceAll(out, k, t.Format(v))
|
||||||
|
}
|
||||||
|
// Replace index placeholders
|
||||||
|
out = strings.ReplaceAll(out, "{z}", fmt.Sprintf("%d", z))
|
||||||
|
out = strings.ReplaceAll(out, "{y}", fmt.Sprintf("%d", y))
|
||||||
|
out = strings.ReplaceAll(out, "{x}", fmt.Sprintf("%d", x))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------- NMC -> CMA pipeline -------------------
|
||||||
|
|
||||||
|
type nmcRadar struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nmcResp struct {
|
||||||
|
Radar nmcRadar `json:"radar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var reDigits17 = regexp.MustCompile(`([0-9]{17})`)
|
||||||
|
|
||||||
|
// runOnceFromNMC fetches NMC JSON, extracts timestamp, shifts +8h, then downloads CMA tile for opts.Z/Y/X.
|
||||||
|
func runOnceFromNMC(ctx context.Context, opts Options) error {
|
||||||
|
// 1) Fetch NMC JSON
|
||||||
|
api := "https://www.nmc.cn/rest/weather?stationid=Wqsps"
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, api, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("nmc request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Referer", "https://www.nmc.cn/")
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("nmc get: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("nmc status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 仅从 data.radar.image 读取
|
||||||
|
var top struct {
|
||||||
|
Data struct {
|
||||||
|
Radar nmcRadar `json:"radar"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&top); err != nil {
|
||||||
|
return fmt.Errorf("nmc decode: %w", err)
|
||||||
|
}
|
||||||
|
img := top.Data.Radar.Image
|
||||||
|
if img == "" {
|
||||||
|
return fmt.Errorf("nmc data.radar.image empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Extract filename and 17-digit timestamp from image
|
||||||
|
// Example: /product/2025/09/23/RDCP/SEVP_AOC_RDCP_SLDAS3_ECREF_ANCN_L88_PI_20250923033600000.PNG?v=...
|
||||||
|
// filename: SEVP_AOC_RDCP_SLDAS3_ECREF_ANCN_L88_PI_20250923033600000.PNG
|
||||||
|
// digits: 20250923033600000 -> use first 12 as yyyyMMddHHmm
|
||||||
|
if u, err := neturl.Parse(img); err == nil { // strip query if present
|
||||||
|
img = u.Path
|
||||||
|
}
|
||||||
|
parts := strings.Split(img, "/")
|
||||||
|
fname := parts[len(parts)-1]
|
||||||
|
digits := reDigits17.FindString(fname)
|
||||||
|
if digits == "" {
|
||||||
|
return fmt.Errorf("no 17-digit timestamp in %s", fname)
|
||||||
|
}
|
||||||
|
// Parse yyyyMMddHHmm from first 12 digits as UTC, then +8h
|
||||||
|
utc12 := digits[:12]
|
||||||
|
utcT, err := time.ParseInLocation("200601021504", utc12, time.UTC)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse utc time: %w", err)
|
||||||
|
}
|
||||||
|
local := utcT.Add(8 * time.Hour)
|
||||||
|
|
||||||
|
// 3) Build CMA tile URL(s) for fixed z/y/x
|
||||||
|
dateStr := local.Format("20060102")
|
||||||
|
hh := local.Format("15")
|
||||||
|
mm := local.Format("04")
|
||||||
|
// Prepare tile list: primary (opts or default Nanning) + Guangzhou (7/40/104)
|
||||||
|
z, y, x := opts.Z, opts.Y, opts.X
|
||||||
|
if z == 0 && y == 0 && x == 0 {
|
||||||
|
z, y, x = 7, 40, 102
|
||||||
|
}
|
||||||
|
type tcoord struct{ z, y, x int }
|
||||||
|
tiles := []tcoord{{z, y, x}, {7, 40, 104}}
|
||||||
|
// de-duplicate if same
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, tc := range tiles {
|
||||||
|
key := fmt.Sprintf("%d/%d/%d", tc.z, tc.y, tc.x)
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
if err := downloadAndStoreTile(ctx, local, dateStr, hh, mm, tc.z, tc.y, tc.x, opts); err != nil {
|
||||||
|
log.Printf("[radar] download/store %s failed: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also fetch realtime weather for both radar stations every 10 minutes
|
||||||
|
if err := fetchAndStoreRadarRealtimeFor(ctx, "南宁雷达站", 23.097234, 108.715433); err != nil {
|
||||||
|
log.Printf("[radar] realtime(NN) failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := fetchAndStoreRadarRealtimeFor(ctx, "广州雷达站", 23.146400, 113.341200); err != nil {
|
||||||
|
log.Printf("[radar] realtime(GZ) failed: %v", 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/RADAR_L3_MST_CREF_GISJPG_Tiles_CR/%s/%s/%s/%d/%d/%d.bin", dateStr, hh, mm, z, y, x)
|
||||||
|
fnameOut := fmt.Sprintf("radar_z%d_y%d_x%d_%s.bin", z, y, x, local.Format("20060102_1504"))
|
||||||
|
dest := filepath.Join(opts.OutputDir, fnameOut)
|
||||||
|
if _, err := os.Stat(dest); err == nil {
|
||||||
|
return nil // already exists
|
||||||
|
}
|
||||||
|
if err := httpDownloadTo(ctx, url, dest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[radar] 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("[radar] stored to DB: %s", fnameOut)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return fmt.Errorf("rename: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// fetchAndStoreRadarRealtime calls Caiyun realtime API for the Nanning radar station
|
||||||
|
// and stores selected fields into table `radar_weather` with 10-minute bucketed dt.
|
||||||
|
func fetchAndStoreRadarRealtimeFor(ctx context.Context, alias string, lat, lon float64) error {
|
||||||
|
// Token: prefer env CAIYUN_TOKEN, else config
|
||||||
|
token := os.Getenv("CAIYUN_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
token = config.GetConfig().Forecast.CaiyunToken
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return fmt.Errorf("missing CAIYUN_TOKEN for Caiyun realtime API")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build URL: lon,lat order; metric units
|
||||||
|
api := fmt.Sprintf("https://api.caiyunapp.com/v2.6/%s/%.6f,%.6f/realtime?lang=zh_CN&unit=metric", token, lon, lat)
|
||||||
|
client := &http.Client{Timeout: 12 * time.Second}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, api, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build realtime request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("realtime http get: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("realtime status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Result struct {
|
||||||
|
Realtime struct {
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
Humidity float64 `json:"humidity"`
|
||||||
|
Cloudrate float64 `json:"cloudrate"`
|
||||||
|
Visibility float64 `json:"visibility"`
|
||||||
|
Dswrf float64 `json:"dswrf"`
|
||||||
|
Wind struct {
|
||||||
|
Speed float64 `json:"speed"`
|
||||||
|
Direction float64 `json:"direction"`
|
||||||
|
} `json:"wind"`
|
||||||
|
Pressure float64 `json:"pressure"`
|
||||||
|
} `json:"realtime"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return fmt.Errorf("decode realtime: %w", err)
|
||||||
|
}
|
||||||
|
if payload.Status != "ok" {
|
||||||
|
return fmt.Errorf("realtime api status=%s", payload.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Align to 10-minute bucket in Asia/Shanghai
|
||||||
|
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if loc == nil {
|
||||||
|
loc = time.FixedZone("CST", 8*3600)
|
||||||
|
}
|
||||||
|
dt := roundDownN(time.Now().In(loc), 10*time.Minute)
|
||||||
|
|
||||||
|
// Store
|
||||||
|
db := database.GetDB()
|
||||||
|
rt := payload.Result.Realtime
|
||||||
|
// Caiyun wind.speed is in km/h under metric; convert to m/s for storage/display
|
||||||
|
windSpeedMS := rt.Wind.Speed / 3.6
|
||||||
|
return database.UpsertRadarWeather(ctx, db, alias, lat, lon, dt,
|
||||||
|
rt.Temperature, rt.Humidity, rt.Cloudrate, rt.Visibility, rt.Dswrf,
|
||||||
|
windSpeedMS, rt.Wind.Direction, rt.Pressure,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envEnabledDefaultTrue() bool {
|
||||||
|
v := strings.ToLower(strings.TrimSpace(os.Getenv("RADAR_ENABLED")))
|
||||||
|
if v == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvDefault(key, def string) string {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvIntDefault(key string, def int) int {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
_, err := fmt.Sscanf(v, "%d", &n)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
84
internal/radar/store.go
Normal file
84
internal/radar/store.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package radar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"weatherstation/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Matches .../<PRODUCT>/<YYYYMMDD>/<HH>/<mm>/<z>/<y>/<x>.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 CMA radar tile.
|
||||||
|
type TileRef struct {
|
||||||
|
Product string
|
||||||
|
DT time.Time // nominal time in Asia/Shanghai
|
||||||
|
Z, Y, X int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCMATileURL parses a CMA tile URL or path and extracts product, time, z/y/x.
|
||||||
|
// Accepts full URL or path that ends with /tiles/.../x.bin.
|
||||||
|
func ParseCMATileURL(u string) (TileRef, error) {
|
||||||
|
// Normalize path part
|
||||||
|
p := u
|
||||||
|
// Optionally strip query/hash
|
||||||
|
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 CMA 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])
|
||||||
|
|
||||||
|
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if loc == nil {
|
||||||
|
loc = time.FixedZone("CST", 8*3600)
|
||||||
|
}
|
||||||
|
dt, err := time.ParseInLocation("20060102 15 04", fmt.Sprintf("%s %s %s", yyyymmdd, hh, mm), loc)
|
||||||
|
if err != nil {
|
||||||
|
return TileRef{}, fmt.Errorf("parse time: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 radar_tiles.
|
||||||
|
func StoreTileBytes(ctx context.Context, urlOrPath string, data []byte) error {
|
||||||
|
ref, err := ParseCMATileURL(urlOrPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
db := database.GetDB()
|
||||||
|
return database.UpsertRadarTile(ctx, db, ref.Product, ref.DT, ref.Z, ref.Y, ref.X, 256, 256, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportTileFile reads the file and stores it into DB using the URL for metadata.
|
||||||
|
func ImportTileFile(ctx context.Context, urlOrPath, filePath string) error {
|
||||||
|
b, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read file: %w", err)
|
||||||
|
}
|
||||||
|
return StoreTileBytes(ctx, urlOrPath, b)
|
||||||
|
}
|
||||||
@ -1,58 +0,0 @@
|
|||||||
package radarfetch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Caiyun token and endpoint (fixed per user instruction)
|
|
||||||
const caiyunToken = "ZAcZq49qzibr10F0"
|
|
||||||
|
|
||||||
type caiyunRealtimeResp struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Result struct {
|
|
||||||
Realtime struct {
|
|
||||||
Temperature float64 `json:"temperature"`
|
|
||||||
Humidity float64 `json:"humidity"`
|
|
||||||
Pressure float64 `json:"pressure"`
|
|
||||||
Wind struct {
|
|
||||||
Speed float64 `json:"speed"`
|
|
||||||
Direction float64 `json:"direction"`
|
|
||||||
} `json:"wind"`
|
|
||||||
} `json:"realtime"`
|
|
||||||
} `json:"result"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchCaiyunRealtime fetches 10m wind plus T/RH/P for given lon,lat.
|
|
||||||
// Returns: speed(m/s), dir_from(deg), tempC, humidity(0-1), pressurePa
|
|
||||||
func FetchCaiyunRealtime(lon, lat float64) (float64, float64, float64, float64, float64, error) {
|
|
||||||
url := fmt.Sprintf("https://api.caiyunapp.com/v2.6/%s/%.6f,%.6f/realtime?unit=metric", caiyunToken, lon, lat)
|
|
||||||
req, _ := http.NewRequest("GET", url, nil)
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
cli := &http.Client{Timeout: 8 * time.Second}
|
|
||||||
resp, err := cli.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, 0, 0, 0, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return 0, 0, 0, 0, 0, fmt.Errorf("caiyun http %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
var rr caiyunRealtimeResp
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&rr); err != nil {
|
|
||||||
return 0, 0, 0, 0, 0, err
|
|
||||||
}
|
|
||||||
if rr.Status != "ok" {
|
|
||||||
return 0, 0, 0, 0, 0, fmt.Errorf("caiyun status %s", rr.Status)
|
|
||||||
}
|
|
||||||
rt := rr.Result.Realtime
|
|
||||||
return rt.Wind.Speed, rt.Wind.Direction, rt.Temperature, rt.Humidity, rt.Pressure, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward-compatible wrapper (wind only)
|
|
||||||
func FetchCaiyunWind(lon, lat float64) (float64, float64, error) {
|
|
||||||
s, d, _, _, _, err := FetchCaiyunRealtime(lon, lat)
|
|
||||||
return s, d, err
|
|
||||||
}
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
package radarfetch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"image"
|
|
||||||
"image/color"
|
|
||||||
"image/png"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AttachClusterPNGs renders a tiny PNG for each cluster by flood-filling
|
|
||||||
// from its centroid on the thresholded mask, cropping to the cluster bbox.
|
|
||||||
// It writes files to outDir/clusters/cluster-<id>.png and returns updated clusters
|
|
||||||
// with PNG field filled.
|
|
||||||
func AttachClusterPNGs(grid [][]*float64, thr float64, clusters []Cluster, outDir string) ([]Cluster, error) {
|
|
||||||
const W, H = 256, 256
|
|
||||||
if len(grid) != H || (len(grid) > 0 && len(grid[0]) != W) {
|
|
||||||
return clusters, nil
|
|
||||||
}
|
|
||||||
// precompute threshold mask
|
|
||||||
mask := make([][]bool, H)
|
|
||||||
for r := 0; r < H; r++ {
|
|
||||||
mask[r] = make([]bool, W)
|
|
||||||
for c := 0; c < W; c++ {
|
|
||||||
if grid[r][c] == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if *grid[r][c] >= thr {
|
|
||||||
mask[r][c] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
outDir = filepath.Join(outDir, "clusters")
|
|
||||||
_ = os.MkdirAll(outDir, 0o755)
|
|
||||||
for i := range clusters {
|
|
||||||
cl := &clusters[i]
|
|
||||||
// BFS from (Row,Col) within mask to reconstruct membership
|
|
||||||
r0, c0 := cl.Row, cl.Col
|
|
||||||
if r0 < 0 || r0 >= H || c0 < 0 || c0 >= W || !mask[r0][c0] {
|
|
||||||
// skip if centroid not on mask
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
minR, minC := cl.MinRow, cl.MinCol
|
|
||||||
maxR, maxC := cl.MaxRow, cl.MaxCol
|
|
||||||
w := maxC - minC + 1
|
|
||||||
h := maxR - minR + 1
|
|
||||||
if w <= 0 || h <= 0 || w > W || h > H {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
||||||
// init transparent
|
|
||||||
for y := 0; y < h; y++ {
|
|
||||||
for x := 0; x < w; x++ {
|
|
||||||
img.SetRGBA(x, y, color.RGBA{0, 0, 0, 0})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// flood fill within bbox
|
|
||||||
vis := make([][]bool, H)
|
|
||||||
for r := 0; r < H; r++ {
|
|
||||||
vis[r] = make([]bool, W)
|
|
||||||
}
|
|
||||||
stack := [][2]int{{r0, c0}}
|
|
||||||
vis[r0][c0] = true
|
|
||||||
dirs := [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}, {-1, -1}, {-1, 1}, {1, -1}, {1, 1}}
|
|
||||||
for len(stack) > 0 {
|
|
||||||
cur := stack[len(stack)-1]
|
|
||||||
stack = stack[:len(stack)-1]
|
|
||||||
rr, cc := cur[0], cur[1]
|
|
||||||
if rr < minR || rr > maxR || cc < minC || cc > maxC {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// paint
|
|
||||||
dbz := grid[rr][cc]
|
|
||||||
if dbz != nil {
|
|
||||||
col := colorForDBZ(*dbz)
|
|
||||||
img.SetRGBA(cc-minC, rr-minR, col)
|
|
||||||
}
|
|
||||||
for _, d := range dirs {
|
|
||||||
nr, nc := rr+d[0], cc+d[1]
|
|
||||||
if nr < 0 || nr >= H || nc < 0 || nc >= W {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if vis[nr][nc] || !mask[nr][nc] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
vis[nr][nc] = true
|
|
||||||
stack = append(stack, [2]int{nr, nc})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// write file
|
|
||||||
name := fmt.Sprintf("cluster-%d.png", cl.ID)
|
|
||||||
p := filepath.Join(outDir, name)
|
|
||||||
f, err := os.Create(p)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_ = png.Encode(f, img)
|
|
||||||
_ = f.Close()
|
|
||||||
cl.PNG = filepath.Join(filepath.Base(outDir), name)
|
|
||||||
}
|
|
||||||
return clusters, nil
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
package radarfetch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const DefaultUA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
|
|
||||||
|
|
||||||
func GetWithUA(url string, ua string, timeout time.Duration, extraHeaders map[string]string) ([]byte, error) {
|
|
||||||
c := &http.Client{Timeout: timeout}
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if ua == "" {
|
|
||||||
ua = DefaultUA
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", ua)
|
|
||||||
for k, v := range extraHeaders {
|
|
||||||
req.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
resp, err := c.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
||||||
return nil, io.ErrUnexpectedEOF
|
|
||||||
}
|
|
||||||
b, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
@ -1,242 +0,0 @@
|
|||||||
package radarfetch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StartJob launches a background ticker to download NMC images and CMA bin using Huanan time.
|
|
||||||
type Options struct {
|
|
||||||
OutRoot string
|
|
||||||
TZOffset int
|
|
||||||
Interval time.Duration
|
|
||||||
NMCChinaURL string
|
|
||||||
NMCHuananURL string
|
|
||||||
NMCNanningURL string
|
|
||||||
CMABase string
|
|
||||||
Z, Y, X int
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunOnce executes a single download-render-save cycle.
|
|
||||||
func RunOnce(opts Options) error {
|
|
||||||
if opts.OutRoot == "" {
|
|
||||||
opts.OutRoot = "./radar_data"
|
|
||||||
}
|
|
||||||
if opts.TZOffset == 0 {
|
|
||||||
opts.TZOffset = 8
|
|
||||||
}
|
|
||||||
if opts.NMCChinaURL == "" {
|
|
||||||
opts.NMCChinaURL = "https://www.nmc.cn/publish/radar/chinaall.html"
|
|
||||||
}
|
|
||||||
if opts.NMCHuananURL == "" {
|
|
||||||
opts.NMCHuananURL = "https://www.nmc.cn/publish/radar/huanan.html"
|
|
||||||
}
|
|
||||||
if opts.NMCNanningURL == "" {
|
|
||||||
opts.NMCNanningURL = "https://www.nmc.cn/publish/radar/guang-xi/nan-ning.htm"
|
|
||||||
}
|
|
||||||
if opts.CMABase == "" {
|
|
||||||
opts.CMABase = "https://image.data.cma.cn"
|
|
||||||
}
|
|
||||||
if opts.Z == 0 && opts.Y == 0 && opts.X == 0 {
|
|
||||||
opts.Z, opts.Y, opts.X = 7, 40, 102
|
|
||||||
}
|
|
||||||
fmt.Printf("[radar] start run: out=%s z/y/x=%d/%d/%d tz=%d\n", opts.OutRoot, opts.Z, opts.Y, opts.X, opts.TZOffset)
|
|
||||||
err := runDownload(opts.OutRoot, opts.TZOffset, opts.NMCChinaURL, opts.NMCHuananURL, opts.NMCNanningURL, opts.CMABase, opts.Z, opts.Y, opts.X)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("[radar] run failed: %v\n", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Println("[radar] run done")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run starts the periodic downloader (blocking ticker loop).
|
|
||||||
func Run(opts Options) {
|
|
||||||
if opts.OutRoot == "" {
|
|
||||||
opts.OutRoot = "./radar_data"
|
|
||||||
}
|
|
||||||
if opts.TZOffset == 0 {
|
|
||||||
opts.TZOffset = 8
|
|
||||||
}
|
|
||||||
if opts.Interval <= 0 {
|
|
||||||
opts.Interval = 10 * time.Minute
|
|
||||||
}
|
|
||||||
if opts.NMCChinaURL == "" {
|
|
||||||
opts.NMCChinaURL = "https://www.nmc.cn/publish/radar/chinaall.html"
|
|
||||||
}
|
|
||||||
if opts.NMCHuananURL == "" {
|
|
||||||
opts.NMCHuananURL = "https://www.nmc.cn/publish/radar/huanan.html"
|
|
||||||
}
|
|
||||||
if opts.NMCNanningURL == "" {
|
|
||||||
opts.NMCNanningURL = "https://www.nmc.cn/publish/radar/guang-xi/nan-ning.htm"
|
|
||||||
}
|
|
||||||
if opts.CMABase == "" {
|
|
||||||
opts.CMABase = "https://image.data.cma.cn"
|
|
||||||
}
|
|
||||||
if opts.Z == 0 && opts.Y == 0 && opts.X == 0 {
|
|
||||||
opts.Z, opts.Y, opts.X = 7, 40, 102
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = RunOnce(opts)
|
|
||||||
t := time.NewTicker(opts.Interval)
|
|
||||||
for range t.C {
|
|
||||||
_ = RunOnce(opts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDownload(outRoot string, tzOffset int, chinaURL, huananURL, nanningURL, cmaBase string, z, y, x int) error {
|
|
||||||
// 1) Fetch NMC pages and parse image/time (time from Huanan)
|
|
||||||
fmt.Println("[radar] fetch NMC China page ...")
|
|
||||||
chinaHTML, err := GetWithUA(chinaURL, DefaultUA, 15*time.Second, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("fetch NMC China: %w", err)
|
|
||||||
}
|
|
||||||
chinaImg, _, ok := ExtractFirstImageAndTime(chinaHTML)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("parse China page: data-img not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("[radar] fetch NMC Huanan page ...")
|
|
||||||
huananHTML, err := GetWithUA(huananURL, DefaultUA, 15*time.Second, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("fetch NMC Huanan: %w", err)
|
|
||||||
}
|
|
||||||
huananImg, huananTime, ok := ExtractFirstImageAndTime(huananHTML)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("parse Huanan page: data-img not found")
|
|
||||||
}
|
|
||||||
date, hour, minute, tsLocal := ParseNmcTime(huananTime, tzOffset)
|
|
||||||
|
|
||||||
fmt.Println("[radar] fetch NMC Nanning page ...")
|
|
||||||
nanningHTML, err := GetWithUA(nanningURL, DefaultUA, 15*time.Second, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("fetch NMC Nanning: %w", err)
|
|
||||||
}
|
|
||||||
nanningImg, _, ok := ExtractFirstImageAndTime(nanningHTML)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("parse Nanning page: data-img not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare out directory
|
|
||||||
outDir := filepath.Join(outRoot, fmt.Sprintf("%04d%02d%02d", date/10000, (date/100)%100, date%100), fmt.Sprintf("%02d", hour), fmt.Sprintf("%02d", minute))
|
|
||||||
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
|
||||||
return fmt.Errorf("mkdir outDir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download three images (with Referer)
|
|
||||||
imgHeaders := map[string]string{
|
|
||||||
"Referer": "https://www.nmc.cn/",
|
|
||||||
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
|
||||||
}
|
|
||||||
fmt.Println("[radar] download China/Huanan/Nanning images ...")
|
|
||||||
if b, err := GetWithUA(chinaImg, DefaultUA, 20*time.Second, imgHeaders); err == nil {
|
|
||||||
_ = os.WriteFile(filepath.Join(outDir, "nmc_chinaall.png"), b, 0o644)
|
|
||||||
}
|
|
||||||
if b, err := GetWithUA(huananImg, DefaultUA, 20*time.Second, imgHeaders); err == nil {
|
|
||||||
_ = os.WriteFile(filepath.Join(outDir, "nmc_huanan.png"), b, 0o644)
|
|
||||||
}
|
|
||||||
if b, err := GetWithUA(nanningImg, DefaultUA, 20*time.Second, imgHeaders); err == nil {
|
|
||||||
_ = os.WriteFile(filepath.Join(outDir, "nmc_nanning.png"), b, 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Fetch CMA BIN with Huanan time
|
|
||||||
binURL := BuildCMAURL(cmaBase, date, hour, minute, z, y, x)
|
|
||||||
binHeaders := map[string]string{
|
|
||||||
"Referer": "https://data.cma.cn/",
|
|
||||||
"Origin": "https://data.cma.cn",
|
|
||||||
"User-Agent": DefaultUA,
|
|
||||||
"Accept": "*/*",
|
|
||||||
}
|
|
||||||
fmt.Printf("[radar] download CMA bin z/y/x=%d/%d/%d ...\n", z, y, x)
|
|
||||||
binBytes, err := GetWithUA(binURL, DefaultUA, 30*time.Second, binHeaders)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("fetch BIN: %w", err)
|
|
||||||
}
|
|
||||||
binPath := filepath.Join(outDir, fmt.Sprintf("%d-%d-%d.bin", z, y, x))
|
|
||||||
if err := os.WriteFile(binPath, binBytes, 0o644); err != nil {
|
|
||||||
return fmt.Errorf("save BIN: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render BIN -> PNG
|
|
||||||
cmaPNG := filepath.Join(outDir, fmt.Sprintf("cma_%d-%d-%d.png", z, y, x))
|
|
||||||
fmt.Println("[radar] render bin -> png ...")
|
|
||||||
if err := RenderBinToPNG(binPath, cmaPNG, true); err != nil {
|
|
||||||
return fmt.Errorf("render PNG: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode grid and detect clusters (>=40 dBZ)
|
|
||||||
fmt.Println("[radar] decode grid & detect clusters ...")
|
|
||||||
grid := make([][]*float64, 256)
|
|
||||||
{
|
|
||||||
const w, h = 256, 256
|
|
||||||
if len(binBytes) == w*h*2 {
|
|
||||||
for row := 0; row < h; row++ {
|
|
||||||
line := make([]*float64, w)
|
|
||||||
for col := 0; col < w; col++ {
|
|
||||||
off := (row*w + col) * 2
|
|
||||||
u := uint16(binBytes[off])<<8 | uint16(binBytes[off+1])
|
|
||||||
vv := int16(u)
|
|
||||||
if vv == 32767 || vv < 0 {
|
|
||||||
line[col] = nil
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dbz := float64(vv) / 10.0
|
|
||||||
line[col] = &dbz
|
|
||||||
}
|
|
||||||
grid[row] = line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Write metadata and update latest
|
|
||||||
w, s, e, n, res := Bounds4326(z, y, x)
|
|
||||||
meta := Metadata{
|
|
||||||
TimestampLocal: tsLocal,
|
|
||||||
Date: date,
|
|
||||||
Hour: hour,
|
|
||||||
Minute: minute,
|
|
||||||
Z: z,
|
|
||||||
Y: y,
|
|
||||||
X: x,
|
|
||||||
Bounds: Bounds{West: w, South: s, East: e, North: n},
|
|
||||||
ResDeg: res,
|
|
||||||
Sources: Sources{NmcHTML: huananURL, NmcImg: huananImg, CmaBin: binURL},
|
|
||||||
Files: Files{HTML: "", PNG: filepath.Join(outDir, "nmc_huanan.png"), BIN: binPath, Metadata: filepath.Join(outDir, "metadata.json"), CMAPNG: cmaPNG},
|
|
||||||
Sizes: Sizes{PNG: fileSize(filepath.Join(outDir, "nmc_huanan.png")), BIN: int64(len(binBytes))},
|
|
||||||
CreatedAt: time.Now().Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
// Attach clusters if grid decoded
|
|
||||||
if grid[0] != nil {
|
|
||||||
meta.Clusters = SegmentClusters(grid, Bounds{West: w, South: s, East: e, North: n}, res, 40.0)
|
|
||||||
// Render small PNGs per cluster
|
|
||||||
if len(meta.Clusters) > 0 {
|
|
||||||
if updated, err2 := AttachClusterPNGs(grid, 40.0, meta.Clusters, outDir); err2 == nil {
|
|
||||||
meta.Clusters = updated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
meta.AnalysisNote = "clusters>=40dBZ; samples=center+4rays (N/S/E/W)"
|
|
||||||
// Build wind query plan with defaults
|
|
||||||
meta.QueryParams = QueryParams{MinAreaPx: 9, StrongDBZOverride: 50, MaxSamplesPerCluster: 5, MaxCandidatesTotal: 25}
|
|
||||||
cl2, cands := PlanWindQuery(meta.Clusters, meta.QueryParams)
|
|
||||||
meta.Clusters = cl2
|
|
||||||
meta.QueryCandidates = cands
|
|
||||||
}
|
|
||||||
if err := WriteMetadata(filepath.Join(outDir, "metadata.json"), &meta); err != nil {
|
|
||||||
return fmt.Errorf("write metadata: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Println("[radar] update latest snapshot ...")
|
|
||||||
if err := UpdateLatest(outRoot, outDir, &meta); err != nil {
|
|
||||||
return fmt.Errorf("update latest: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileSize(p string) int64 {
|
|
||||||
fi, err := os.Stat(p)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return fi.Size()
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
package radarfetch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ExtractFirstImageAndTime tries to extract data-img and data-time from NMC HTML.
|
|
||||||
// It first searches for an element with class "time" that carries data-img/time;
|
|
||||||
// falls back to the first occurrence of data-img / data-time attributes in the HTML.
|
|
||||||
func ExtractFirstImageAndTime(html []byte) (img string, timeStr string, ok bool) {
|
|
||||||
// naive scan for data-img and data-time on the same segment first
|
|
||||||
// Search for class="time" anchor to bias to the right element
|
|
||||||
idx := bytes.Index(html, []byte("class=\"time\""))
|
|
||||||
start := 0
|
|
||||||
if idx >= 0 {
|
|
||||||
// back up a bit to include attributes on same tag
|
|
||||||
if idx > 200 {
|
|
||||||
start = idx - 200
|
|
||||||
} else {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seg := html[start:]
|
|
||||||
img = findAttr(seg, "data-img")
|
|
||||||
timeStr = findAttr(seg, "data-time")
|
|
||||||
if img != "" {
|
|
||||||
return img, timeStr, true
|
|
||||||
}
|
|
||||||
// fallback: first data-img anywhere
|
|
||||||
img = findAttr(html, "data-img")
|
|
||||||
timeStr = findAttr(html, "data-time")
|
|
||||||
if img != "" {
|
|
||||||
return img, timeStr, true
|
|
||||||
}
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
func findAttr(b []byte, key string) string {
|
|
||||||
// look for key="..."
|
|
||||||
pat := []byte(key + "=\"")
|
|
||||||
i := bytes.Index(b, pat)
|
|
||||||
if i < 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
i += len(pat)
|
|
||||||
j := bytes.IndexByte(b[i:], '"')
|
|
||||||
if j < 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return string(b[i : i+j])
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
package radarfetch
|
|
||||||
|
|
||||||
// PlanWindQuery marks clusters as eligible or not based on params and
|
|
||||||
// returns a flattened list of sample points for eligible clusters.
|
|
||||||
func PlanWindQuery(clusters []Cluster, params QueryParams) ([]Cluster, []QueryCandidate) {
|
|
||||||
if params.MinAreaPx <= 0 {
|
|
||||||
params.MinAreaPx = 9
|
|
||||||
}
|
|
||||||
if params.StrongDBZOverride <= 0 {
|
|
||||||
params.StrongDBZOverride = 50
|
|
||||||
}
|
|
||||||
if params.MaxSamplesPerCluster <= 0 {
|
|
||||||
params.MaxSamplesPerCluster = 5
|
|
||||||
}
|
|
||||||
if params.MaxCandidatesTotal <= 0 {
|
|
||||||
params.MaxCandidatesTotal = 25
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]QueryCandidate, 0, len(clusters)*2)
|
|
||||||
for i := range clusters {
|
|
||||||
cl := &clusters[i]
|
|
||||||
eligible := cl.AreaPx >= params.MinAreaPx || cl.MaxDBZ >= params.StrongDBZOverride
|
|
||||||
if !eligible {
|
|
||||||
cl.EligibleForQuery = false
|
|
||||||
cl.SkipReason = "too_small_and_weak"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cl.EligibleForQuery = true
|
|
||||||
cl.SkipReason = ""
|
|
||||||
// choose up to MaxSamplesPerCluster from samples (prefer center first)
|
|
||||||
if len(cl.Samples) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// order: center first, then others as-is
|
|
||||||
picked := 0
|
|
||||||
// ensure center first if exists
|
|
||||||
for _, s := range cl.Samples {
|
|
||||||
if s.Role == "center" {
|
|
||||||
out = append(out, QueryCandidate{ClusterID: cl.ID, Role: s.Role, Lon: s.Lon, Lat: s.Lat})
|
|
||||||
picked++
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, s := range cl.Samples {
|
|
||||||
if picked >= params.MaxSamplesPerCluster {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if s.Role == "center" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, QueryCandidate{ClusterID: cl.ID, Role: s.Role, Lon: s.Lon, Lat: s.Lat})
|
|
||||||
picked++
|
|
||||||
}
|
|
||||||
if picked == 0 {
|
|
||||||
// fallback: take first
|
|
||||||
s := cl.Samples[0]
|
|
||||||
out = append(out, QueryCandidate{ClusterID: cl.ID, Role: s.Role, Lon: s.Lon, Lat: s.Lat})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// cap total
|
|
||||||
if len(out) > params.MaxCandidatesTotal {
|
|
||||||
out = out[:params.MaxCandidatesTotal]
|
|
||||||
}
|
|
||||||
return clusters, out
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
package radarfetch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
)
|
|
||||||
|
|
||||||
func BuildCMAURL(base string, date int, hour int, minute int, z int, y int, x int) string {
|
|
||||||
yyyy := date / 10000
|
|
||||||
mm := (date / 100) % 100
|
|
||||||
dd := date % 100
|
|
||||||
return fmt.Sprintf("%s/tiles/China/RADAR_L3_MST_CREF_GISJPG_Tiles_CR/%04d%02d%02d/%02d/%02d/%d/%d/%d.bin",
|
|
||||||
trimSlash(base), yyyy, mm, dd, hour, minute, z, y, x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Bounds4326(z, y, x int) (west, south, east, north, resDeg float64) {
|
|
||||||
step := 360.0 / math.Ldexp(1.0, z)
|
|
||||||
west = -180.0 + float64(x)*step
|
|
||||||
east = west + step
|
|
||||||
south = -90.0 + float64(y)*step
|
|
||||||
north = south + step
|
|
||||||
resDeg = step / 256.0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func trimSlash(s string) string {
|
|
||||||
n := len(s)
|
|
||||||
for n > 0 && s[n-1] == '/' {
|
|
||||||
s = s[:n-1]
|
|
||||||
n--
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
package radarfetch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image"
|
|
||||||
"image/color"
|
|
||||||
"image/png"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
var colors15 = []string{
|
|
||||||
"#0000F6", "#01A0F6", "#00ECEC", "#01FF00", "#00C800",
|
|
||||||
"#019000", "#FFFF00", "#E7C000", "#FF9000", "#FF0000",
|
|
||||||
"#D60000", "#C00000", "#FF00F0", "#780084", "#AD90F0",
|
|
||||||
}
|
|
||||||
|
|
||||||
func hexToRGBA(s string, a uint8) color.RGBA {
|
|
||||||
if len(s) >= 7 && s[0] == '#' {
|
|
||||||
r := xtoi(s[1:3])
|
|
||||||
g := xtoi(s[3:5])
|
|
||||||
b := xtoi(s[5:7])
|
|
||||||
return color.RGBA{uint8(r), uint8(g), uint8(b), a}
|
|
||||||
}
|
|
||||||
return color.RGBA{0, 0, 0, 0}
|
|
||||||
}
|
|
||||||
|
|
||||||
func xtoi(h string) int {
|
|
||||||
v := 0
|
|
||||||
for i := 0; i < len(h); i++ {
|
|
||||||
c := h[i]
|
|
||||||
v <<= 4
|
|
||||||
switch {
|
|
||||||
case c >= '0' && c <= '9':
|
|
||||||
v |= int(c - '0')
|
|
||||||
case c >= 'a' && c <= 'f':
|
|
||||||
v |= int(c-'a') + 10
|
|
||||||
case c >= 'A' && c <= 'F':
|
|
||||||
v |= int(c-'A') + 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func colorForDBZ(dbz float64) color.RGBA {
|
|
||||||
if dbz < 0 {
|
|
||||||
return color.RGBA{0, 0, 0, 0}
|
|
||||||
}
|
|
||||||
idx := int(dbz / 5.0)
|
|
||||||
if idx < 0 {
|
|
||||||
idx = 0
|
|
||||||
}
|
|
||||||
if idx >= len(colors15) {
|
|
||||||
idx = len(colors15) - 1
|
|
||||||
}
|
|
||||||
return hexToRGBA(colors15[idx], 255)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderBinToPNG renders 256x256 BE int16 .bin into a PNG using CMA-style colors.
|
|
||||||
func RenderBinToPNG(srcPath, dstPath string, flipY bool) error {
|
|
||||||
b, err := os.ReadFile(srcPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
const w, h = 256, 256
|
|
||||||
if len(b) != w*h*2 {
|
|
||||||
return ErrSize
|
|
||||||
}
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
||||||
for row := 0; row < h; row++ {
|
|
||||||
outRow := row
|
|
||||||
if flipY {
|
|
||||||
outRow = h - 1 - row
|
|
||||||
}
|
|
||||||
for col := 0; col < w; col++ {
|
|
||||||
off := (row*w + col) * 2
|
|
||||||
u := uint16(b[off])<<8 | uint16(b[off+1])
|
|
||||||
v := int16(u)
|
|
||||||
if v == 32767 || v < 0 {
|
|
||||||
img.SetRGBA(col, outRow, color.RGBA{0, 0, 0, 0})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dbz := float64(v) / 10.0
|
|
||||||
img.SetRGBA(col, outRow, colorForDBZ(dbz))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
f, err := os.Create(dstPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
return png.Encode(f, img)
|
|
||||||
}
|
|
||||||
|
|
||||||
var ErrSize = errSize{}
|
|
||||||
|
|
||||||
type errSize struct{}
|
|
||||||
|
|
||||||
func (errSize) Error() string { return "unexpected .bin size (expected 131072 bytes)" }
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
package radarfetch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SegmentClusters finds 8-connected regions where dBZ >= thr (e.g., 40),
|
|
||||||
// computes stats and recommended sampling points per cluster.
|
|
||||||
// Input grid: 256x256, invalid as NaN; bounds/res used to compute lon/lat.
|
|
||||||
func SegmentClusters(grid [][]*float64, bounds Bounds, resDeg float64, thr float64) []Cluster {
|
|
||||||
const W, H = 256, 256
|
|
||||||
if len(grid) != H || (len(grid) > 0 && len(grid[0]) != W) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Mask of eligible pixels
|
|
||||||
mask := make([][]bool, H)
|
|
||||||
for r := 0; r < H; r++ {
|
|
||||||
mask[r] = make([]bool, W)
|
|
||||||
for c := 0; c < W; c++ {
|
|
||||||
if grid[r][c] == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
v := *grid[r][c]
|
|
||||||
if v >= thr {
|
|
||||||
mask[r][c] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visited flags
|
|
||||||
vis := make([][]bool, H)
|
|
||||||
for r := 0; r < H; r++ {
|
|
||||||
vis[r] = make([]bool, W)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8-neighborhood
|
|
||||||
nbr := [8][2]int{{-1, -1}, {-1, 0}, {-1, 1}, {0, -1}, {0, 1}, {1, -1}, {1, 0}, {1, 1}}
|
|
||||||
|
|
||||||
var clusters []Cluster
|
|
||||||
clusterID := 0
|
|
||||||
for r := 0; r < H; r++ {
|
|
||||||
for c := 0; c < W; c++ {
|
|
||||||
if !mask[r][c] || vis[r][c] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// BFS/DFS stack
|
|
||||||
stack := [][2]int{{r, c}}
|
|
||||||
vis[r][c] = true
|
|
||||||
// stats
|
|
||||||
area := 0
|
|
||||||
sumW := 0.0
|
|
||||||
sumWR := 0.0
|
|
||||||
sumWC := 0.0
|
|
||||||
maxDBZ := -math.MaxFloat64
|
|
||||||
sumDBZ := 0.0
|
|
||||||
minR, minC := r, c
|
|
||||||
maxR, maxC := r, c
|
|
||||||
pixels := make([][2]int, 0, 512)
|
|
||||||
for len(stack) > 0 {
|
|
||||||
cur := stack[len(stack)-1]
|
|
||||||
stack = stack[:len(stack)-1]
|
|
||||||
rr, cc := cur[0], cur[1]
|
|
||||||
area++
|
|
||||||
dbz := *grid[rr][cc]
|
|
||||||
w := dbz // dBZ-weighted centroid
|
|
||||||
sumW += w
|
|
||||||
sumWR += float64(rr) * w
|
|
||||||
sumWC += float64(cc) * w
|
|
||||||
if dbz > maxDBZ {
|
|
||||||
maxDBZ = dbz
|
|
||||||
}
|
|
||||||
sumDBZ += dbz
|
|
||||||
if rr < minR {
|
|
||||||
minR = rr
|
|
||||||
}
|
|
||||||
if cc < minC {
|
|
||||||
minC = cc
|
|
||||||
}
|
|
||||||
if rr > maxR {
|
|
||||||
maxR = rr
|
|
||||||
}
|
|
||||||
if cc > maxC {
|
|
||||||
maxC = cc
|
|
||||||
}
|
|
||||||
pixels = append(pixels, [2]int{rr, cc})
|
|
||||||
for _, d := range nbr {
|
|
||||||
nr, nc := rr+d[0], cc+d[1]
|
|
||||||
if nr < 0 || nr >= H || nc < 0 || nc >= W {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if vis[nr][nc] || !mask[nr][nc] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
vis[nr][nc] = true
|
|
||||||
stack = append(stack, [2]int{nr, nc})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if area == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// centroid (row/col)
|
|
||||||
cr, cc := 0.0, 0.0
|
|
||||||
if sumW > 0 {
|
|
||||||
cr = sumWR / sumW
|
|
||||||
cc = sumWC / sumW
|
|
||||||
} else {
|
|
||||||
// fallback to geometric center
|
|
||||||
cr = float64(minR+maxR) / 2.0
|
|
||||||
cc = float64(minC+maxC) / 2.0
|
|
||||||
}
|
|
||||||
// Convert centroid to lon/lat (pixel center)
|
|
||||||
clon := bounds.West + (cc+0.5)*resDeg
|
|
||||||
clat := bounds.South + (cr+0.5)*resDeg
|
|
||||||
|
|
||||||
// Sample points: center + four rays (N,S,E,W) until boundary
|
|
||||||
samples := make([]Sample, 0, 5)
|
|
||||||
samples = append(samples, Sample{Row: int(math.Round(cr)), Col: int(math.Round(cc)), Lon: clon, Lat: clat, Role: "center"})
|
|
||||||
// helper to step ray and clamp to last in-mask pixel
|
|
||||||
stepRay := func(dr, dc int, role string) {
|
|
||||||
rr := int(math.Round(cr))
|
|
||||||
cc2 := int(math.Round(cc))
|
|
||||||
lastR, lastC := rr, cc2
|
|
||||||
for {
|
|
||||||
rr += dr
|
|
||||||
cc2 += dc
|
|
||||||
if rr < 0 || rr >= H || cc2 < 0 || cc2 >= W {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if !mask[rr][cc2] {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
lastR, lastC = rr, cc2
|
|
||||||
}
|
|
||||||
lon := bounds.West + (float64(lastC)+0.5)*resDeg
|
|
||||||
lat := bounds.South + (float64(lastR)+0.5)*resDeg
|
|
||||||
if lastR != samples[0].Row || lastC != samples[0].Col {
|
|
||||||
samples = append(samples, Sample{Row: lastR, Col: lastC, Lon: lon, Lat: lat, Role: role})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stepRay(-1, 0, "ray_n")
|
|
||||||
stepRay(1, 0, "ray_s")
|
|
||||||
stepRay(0, 1, "ray_e")
|
|
||||||
stepRay(0, -1, "ray_w")
|
|
||||||
|
|
||||||
avgDBZ := sumDBZ / float64(area)
|
|
||||||
cluster := Cluster{
|
|
||||||
ID: clusterID,
|
|
||||||
AreaPx: area,
|
|
||||||
MaxDBZ: maxDBZ,
|
|
||||||
AvgDBZ: avgDBZ,
|
|
||||||
Row: int(math.Round(cr)),
|
|
||||||
Col: int(math.Round(cc)),
|
|
||||||
Lon: clon,
|
|
||||||
Lat: clat,
|
|
||||||
MinRow: minR, MinCol: minC, MaxRow: maxR, MaxCol: maxC,
|
|
||||||
Samples: samples,
|
|
||||||
}
|
|
||||||
clusters = append(clusters, cluster)
|
|
||||||
clusterID++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return clusters
|
|
||||||
}
|
|
||||||
@ -1,159 +0,0 @@
|
|||||||
package radarfetch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Bounds struct {
|
|
||||||
West float64 `json:"west"`
|
|
||||||
South float64 `json:"south"`
|
|
||||||
East float64 `json:"east"`
|
|
||||||
North float64 `json:"north"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Sources struct {
|
|
||||||
NmcHTML string `json:"nmc_html"`
|
|
||||||
NmcImg string `json:"nmc_img"`
|
|
||||||
CmaBin string `json:"cma_bin"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Files struct {
|
|
||||||
HTML string `json:"html"`
|
|
||||||
PNG string `json:"png"`
|
|
||||||
BIN string `json:"bin"`
|
|
||||||
Metadata string `json:"metadata"`
|
|
||||||
CMAPNG string `json:"cma_png"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Sizes struct {
|
|
||||||
PNG int64 `json:"png"`
|
|
||||||
BIN int64 `json:"bin"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Metadata struct {
|
|
||||||
TimestampLocal string `json:"timestamp_local"`
|
|
||||||
Date int `json:"date"`
|
|
||||||
Hour int `json:"hour"`
|
|
||||||
Minute int `json:"minute"`
|
|
||||||
Z int `json:"z"`
|
|
||||||
Y int `json:"y"`
|
|
||||||
X int `json:"x"`
|
|
||||||
Bounds Bounds `json:"bounds"`
|
|
||||||
ResDeg float64 `json:"res_deg"`
|
|
||||||
Sources Sources `json:"sources"`
|
|
||||||
Files Files `json:"files"`
|
|
||||||
Sizes Sizes `json:"sizes"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
// Cloud clusters detected from single-frame CREF (>=40 dBZ)
|
|
||||||
// Optional; may be empty when detection fails.
|
|
||||||
Clusters []Cluster `json:"clusters,omitempty"`
|
|
||||||
// Optional notes about sampling strategy or thresholds used
|
|
||||||
AnalysisNote string `json:"analysis_note,omitempty"`
|
|
||||||
// Wind query planning parameters and candidates
|
|
||||||
QueryParams QueryParams `json:"query_params,omitempty"`
|
|
||||||
QueryCandidates []QueryCandidate `json:"query_candidates,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cluster represents a connected echo region above threshold.
|
|
||||||
type Cluster struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
AreaPx int `json:"area_px"`
|
|
||||||
MaxDBZ float64 `json:"max_dbz"`
|
|
||||||
AvgDBZ float64 `json:"avg_dbz"`
|
|
||||||
// Pixel-space centroid (row, col) using dBZ-weighted center
|
|
||||||
Row int `json:"row"`
|
|
||||||
Col int `json:"col"`
|
|
||||||
// Centroid lon/lat of pixel center
|
|
||||||
Lon float64 `json:"lon"`
|
|
||||||
Lat float64 `json:"lat"`
|
|
||||||
// Bounding box in pixel coords (inclusive)
|
|
||||||
MinRow int `json:"min_row"`
|
|
||||||
MinCol int `json:"min_col"`
|
|
||||||
MaxRow int `json:"max_row"`
|
|
||||||
MaxCol int `json:"max_col"`
|
|
||||||
// Recommended sample points for downstream wind queries
|
|
||||||
Samples []Sample `json:"samples"`
|
|
||||||
// Optional path to a small PNG rendering of this cluster (copied to latest)
|
|
||||||
PNG string `json:"png,omitempty"`
|
|
||||||
// Eligibility for downstream wind query
|
|
||||||
EligibleForQuery bool `json:"eligible_for_query,omitempty"`
|
|
||||||
SkipReason string `json:"skip_reason,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Sample struct {
|
|
||||||
Row int `json:"row"`
|
|
||||||
Col int `json:"col"`
|
|
||||||
Lon float64 `json:"lon"`
|
|
||||||
Lat float64 `json:"lat"`
|
|
||||||
// role: center | ray_n | ray_s | ray_e | ray_w
|
|
||||||
Role string `json:"role"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parameters controlling wind query candidate selection.
|
|
||||||
type QueryParams struct {
|
|
||||||
MinAreaPx int `json:"min_area_px"`
|
|
||||||
StrongDBZOverride float64 `json:"strong_dbz_override"`
|
|
||||||
MaxSamplesPerCluster int `json:"max_samples_per_cluster"`
|
|
||||||
MaxCandidatesTotal int `json:"max_candidates_total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// A single candidate point to query external wind API.
|
|
||||||
type QueryCandidate struct {
|
|
||||||
ClusterID int `json:"cluster_id"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
Lon float64 `json:"lon"`
|
|
||||||
Lat float64 `json:"lat"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func WriteMetadata(path string, m *Metadata) error {
|
|
||||||
b, err := json.MarshalIndent(m, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(path, b, 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateLatest(root string, curDir string, m *Metadata) error {
|
|
||||||
latest := filepath.Join(root, "latest")
|
|
||||||
if err := os.MkdirAll(latest, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Write latest.json
|
|
||||||
b, _ := json.MarshalIndent(struct {
|
|
||||||
Dir string `json:"dir"`
|
|
||||||
Meta *Metadata `json:"meta"`
|
|
||||||
}{Dir: curDir, Meta: m}, "", " ")
|
|
||||||
_ = os.WriteFile(filepath.Join(latest, "latest.json"), b, 0o644)
|
|
||||||
|
|
||||||
copyFile := func(name string) {
|
|
||||||
dst := filepath.Join(latest, name)
|
|
||||||
src := filepath.Join(curDir, name)
|
|
||||||
data, e2 := os.ReadFile(src)
|
|
||||||
if e2 == nil {
|
|
||||||
// ensure parent dir exists for nested paths like "clusters/..."
|
|
||||||
_ = os.MkdirAll(filepath.Dir(dst), 0o755)
|
|
||||||
_ = os.WriteFile(dst, data, 0o644)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
copyFile("nmc_chinaall.png")
|
|
||||||
copyFile("nmc_huanan.png")
|
|
||||||
copyFile("nmc_nanning.png")
|
|
||||||
copyFile("metadata.json")
|
|
||||||
copyFile(fmt.Sprintf("%d-%d-%d.bin", m.Z, m.Y, m.X))
|
|
||||||
if m.Files.CMAPNG != "" {
|
|
||||||
copyFile(filepath.Base(m.Files.CMAPNG))
|
|
||||||
}
|
|
||||||
// copy cluster PNGs if present
|
|
||||||
if len(m.Clusters) > 0 {
|
|
||||||
for _, cl := range m.Clusters {
|
|
||||||
if cl.PNG == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
copyFile(cl.PNG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
package radarfetch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ParseNmcTime parses like "MM/DD HH:MM" with a local tz offset (hours).
|
|
||||||
func ParseNmcTime(s string, tzOffset int) (date int, hour int, minute int, tsLocal string) {
|
|
||||||
parts := strings.Fields(s)
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
md := strings.Split(parts[0], "/")
|
|
||||||
hm := strings.Split(parts[1], ":")
|
|
||||||
if len(md) == 2 && len(hm) == 2 {
|
|
||||||
now := time.Now().UTC().Add(time.Duration(tzOffset) * time.Hour)
|
|
||||||
y := now.Year()
|
|
||||||
m, _ := strconv.Atoi(md[0])
|
|
||||||
d, _ := strconv.Atoi(md[1])
|
|
||||||
h, _ := strconv.Atoi(hm[0])
|
|
||||||
mm, _ := strconv.Atoi(hm[1])
|
|
||||||
loc := time.FixedZone("LOCAL", tzOffset*3600)
|
|
||||||
t := time.Date(y, time.Month(m), d, h, mm, 0, 0, loc)
|
|
||||||
date = t.Year()*10000 + int(t.Month())*100 + t.Day()
|
|
||||||
hour = t.Hour()
|
|
||||||
minute = t.Minute()
|
|
||||||
tsLocal = t.Format("2006-01-02 15:04:05")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
now := time.Now().UTC().Add(time.Duration(tzOffset) * time.Hour)
|
|
||||||
date = now.Year()*10000 + int(now.Month())*100 + now.Day()
|
|
||||||
hour = now.Hour()
|
|
||||||
minute = now.Minute()
|
|
||||||
tsLocal = now.Format("2006-01-02 15:04:05")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@ -1,21 +1,16 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
"weatherstation/internal/config"
|
"weatherstation/internal/config"
|
||||||
"weatherstation/internal/database"
|
"weatherstation/internal/database"
|
||||||
rf "weatherstation/internal/radarfetch"
|
|
||||||
"weatherstation/pkg/types"
|
"weatherstation/pkg/types"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"math"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// StartGinServer 启动Gin Web服务器
|
// StartGinServer 启动Gin Web服务器
|
||||||
@ -31,11 +26,11 @@ func StartGinServer() error {
|
|||||||
|
|
||||||
// 静态文件服务
|
// 静态文件服务
|
||||||
r.Static("/static", "./static")
|
r.Static("/static", "./static")
|
||||||
// 雷达数据静态目录(用于访问 latest 下的图片/二进制)
|
|
||||||
r.Static("/radar", "./radar_data")
|
|
||||||
|
|
||||||
// 路由设置
|
// 路由设置
|
||||||
r.GET("/", indexHandler)
|
r.GET("/", indexHandler)
|
||||||
|
r.GET("/radar/nanning", radarNanningHandler)
|
||||||
|
r.GET("/radar/guangzhou", radarGuangzhouHandler)
|
||||||
|
|
||||||
// API路由组
|
// API路由组
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
@ -44,9 +39,9 @@ func StartGinServer() error {
|
|||||||
api.GET("/stations", getStationsHandler)
|
api.GET("/stations", getStationsHandler)
|
||||||
api.GET("/data", getDataHandler)
|
api.GET("/data", getDataHandler)
|
||||||
api.GET("/forecast", getForecastHandler)
|
api.GET("/forecast", getForecastHandler)
|
||||||
api.GET("/radar/latest", radarLatestHandler)
|
api.GET("/radar/latest", latestRadarTileHandler)
|
||||||
api.GET("/radar/latest/grid", radarLatestGridHandler)
|
api.GET("/radar/weather_latest", latestRadarWeatherHandler)
|
||||||
api.GET("/radar/latest/wind", radarLatestWindHandler)
|
api.GET("/radar/weather_at", radarWeatherAtHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置的Web端口
|
// 获取配置的Web端口
|
||||||
@ -55,8 +50,6 @@ func StartGinServer() error {
|
|||||||
port = 10003 // 默认端口
|
port = 10003 // 默认端口
|
||||||
}
|
}
|
||||||
|
|
||||||
// 备注:雷达抓取改为独立 CLI 触发,Web 服务不自动启动后台任务
|
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
fmt.Printf("Gin Web服务器启动,监听端口 %d...\n", port)
|
fmt.Printf("Gin Web服务器启动,监听端口 %d...\n", port)
|
||||||
return r.Run(fmt.Sprintf(":%d", port))
|
return r.Run(fmt.Sprintf(":%d", port))
|
||||||
@ -73,7 +66,27 @@ func indexHandler(c *gin.Context) {
|
|||||||
c.HTML(http.StatusOK, "index.html", data)
|
c.HTML(http.StatusOK, "index.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 备注:雷达站采用前端 Tab(hash)切换,无需单独路由
|
// radarNanningHandler 南宁雷达站占位页
|
||||||
|
func radarNanningHandler(c *gin.Context) {
|
||||||
|
data := types.PageData{
|
||||||
|
Title: "南宁雷达站",
|
||||||
|
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||||
|
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||||
|
}
|
||||||
|
c.HTML(http.StatusOK, "radar_nanning.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// radarGuangzhouHandler 广州雷达站占位页
|
||||||
|
func radarGuangzhouHandler(c *gin.Context) {
|
||||||
|
data := types.PageData{
|
||||||
|
Title: "广州雷达站",
|
||||||
|
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||||||
|
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||||||
|
}
|
||||||
|
c.HTML(http.StatusOK, "radar_guangzhou.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
// systemStatusHandler 处理系统状态API请求
|
// systemStatusHandler 处理系统状态API请求
|
||||||
func systemStatusHandler(c *gin.Context) {
|
func systemStatusHandler(c *gin.Context) {
|
||||||
@ -218,297 +231,3 @@ func getForecastHandler(c *gin.Context) {
|
|||||||
log.Printf("查询到预报数据: %d 条", len(points))
|
log.Printf("查询到预报数据: %d 条", len(points))
|
||||||
c.JSON(http.StatusOK, points)
|
c.JSON(http.StatusOK, points)
|
||||||
}
|
}
|
||||||
|
|
||||||
// radarLatestHandler 返回最新一次雷达抓取的元数据与图片URL
|
|
||||||
func radarLatestHandler(c *gin.Context) {
|
|
||||||
// 读取 latest/metadata.json
|
|
||||||
latestRoot := "./radar_data/latest"
|
|
||||||
metaPath := latestRoot + "/metadata.json"
|
|
||||||
b, err := os.ReadFile(metaPath)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到最新雷达元数据"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var meta map[string]any
|
|
||||||
if err := json.Unmarshal(b, &meta); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析元数据失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 构造图片URL(通过 /radar/latest/* 静态路径访问)
|
|
||||||
images := map[string]string{
|
|
||||||
"china": "/radar/latest/nmc_chinaall.png",
|
|
||||||
"huanan": "/radar/latest/nmc_huanan.png",
|
|
||||||
"nanning": "/radar/latest/nmc_nanning.png",
|
|
||||||
}
|
|
||||||
if files, ok := meta["files"].(map[string]any); ok {
|
|
||||||
if v, ok2 := files["cma_png"].(string); ok2 && v != "" {
|
|
||||||
_, name := path.Split(v)
|
|
||||||
images["cma"] = "/radar/latest/" + name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"meta": meta,
|
|
||||||
"images": images,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// radarLatestGridHandler 读取 latest 下的 z-y-x.bin 并返回 256x256 的 dBZ 二维数组(无效为 null)
|
|
||||||
func radarLatestGridHandler(c *gin.Context) {
|
|
||||||
latestRoot := "./radar_data/latest"
|
|
||||||
metaPath := latestRoot + "/metadata.json"
|
|
||||||
b, err := os.ReadFile(metaPath)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到最新雷达元数据"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var meta map[string]any
|
|
||||||
if err := json.Unmarshal(b, &meta); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析元数据失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
z := intFromMeta(meta, "z")
|
|
||||||
y := intFromMeta(meta, "y")
|
|
||||||
x := intFromMeta(meta, "x")
|
|
||||||
binName := fmt.Sprintf("%d-%d-%d.bin", z, y, x)
|
|
||||||
binPath := path.Join(latestRoot, binName)
|
|
||||||
buf, err := os.ReadFile(binPath)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到最新BIN文件"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const w, h = 256, 256
|
|
||||||
if len(buf) != w*h*2 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "BIN尺寸异常"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
grid := make([][]*float64, h)
|
|
||||||
for r := 0; r < h; r++ {
|
|
||||||
row := make([]*float64, w)
|
|
||||||
for c2 := 0; c2 < w; c2++ {
|
|
||||||
off := (r*w + c2) * 2
|
|
||||||
u := uint16(buf[off])<<8 | uint16(buf[off+1])
|
|
||||||
v := int16(u)
|
|
||||||
if v == 32767 || v < 0 {
|
|
||||||
row[c2] = nil
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dbz := float64(v) / 10.0
|
|
||||||
row[c2] = &dbz
|
|
||||||
}
|
|
||||||
grid[r] = row
|
|
||||||
}
|
|
||||||
bounds := map[string]float64{"west": 0, "south": 0, "east": 0, "north": 0}
|
|
||||||
if v, ok := meta["bounds"].(map[string]any); ok {
|
|
||||||
if f, ok2 := v["west"].(float64); ok2 {
|
|
||||||
bounds["west"] = f
|
|
||||||
}
|
|
||||||
if f, ok2 := v["south"].(float64); ok2 {
|
|
||||||
bounds["south"] = f
|
|
||||||
}
|
|
||||||
if f, ok2 := v["east"].(float64); ok2 {
|
|
||||||
bounds["east"] = f
|
|
||||||
}
|
|
||||||
if f, ok2 := v["north"].(float64); ok2 {
|
|
||||||
bounds["north"] = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resDeg := 0.0
|
|
||||||
if f, ok := meta["res_deg"].(float64); ok {
|
|
||||||
resDeg = f
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"z": z, "y": y, "x": x,
|
|
||||||
"bounds": bounds,
|
|
||||||
"res_deg": resDeg,
|
|
||||||
"grid": grid,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func intFromMeta(m map[string]any, key string) int {
|
|
||||||
if v, ok := m[key]; ok {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case float64:
|
|
||||||
return int(t)
|
|
||||||
case int:
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// radarLatestWindHandler queries Caiyun realtime wind for the latest query candidates
|
|
||||||
// and provides per-cluster aggregated wind and basic coming/ETA analysis toward station.
|
|
||||||
func radarLatestWindHandler(c *gin.Context) {
|
|
||||||
// 使用极坐标法:对每个云团仅在质心取一次风,直接判定靠近与ETA
|
|
||||||
// 常量:目标点(站点/雷达点)坐标
|
|
||||||
const (
|
|
||||||
stationLat = 23.097234
|
|
||||||
stationLon = 108.715433
|
|
||||||
)
|
|
||||||
// 读取最新元数据
|
|
||||||
latestRoot := "./radar_data/latest"
|
|
||||||
metaPath := latestRoot + "/metadata.json"
|
|
||||||
b, err := os.ReadFile(metaPath)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到最新雷达元数据"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var meta rf.Metadata
|
|
||||||
if err := json.Unmarshal(b, &meta); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析元数据失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 输出结构保持兼容:仍提供 candidates,但每个cluster仅一个(质心)
|
|
||||||
type Wind struct {
|
|
||||||
Speed float64 `json:"speed_ms"`
|
|
||||||
DirFrom float64 `json:"dir_from_deg"`
|
|
||||||
DirTo float64 `json:"dir_to_deg"`
|
|
||||||
U float64 `json:"u_east_ms"`
|
|
||||||
V float64 `json:"v_north_ms"`
|
|
||||||
TempC float64 `json:"temp_c"`
|
|
||||||
RH float64 `json:"rh"` // 0-1
|
|
||||||
PressureHpa float64 `json:"pressure_hpa"`
|
|
||||||
}
|
|
||||||
type CandOut struct {
|
|
||||||
rf.QueryCandidate
|
|
||||||
Wind *Wind `json:"wind,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
outs := make([]CandOut, 0, len(meta.Clusters))
|
|
||||||
|
|
||||||
// 工具函数
|
|
||||||
mPerDegLat := 111320.0
|
|
||||||
mPerDegLon := func(lat float64) float64 { return 111320.0 * math.Cos(lat*math.Pi/180.0) }
|
|
||||||
// 计算极坐标ETA(到站点本身,不再使用侧向与半径作为命中条件)
|
|
||||||
approachETA := func(lonC, latC, speedMS, dirToDeg, lonS, latS float64) (coming bool, etaMin float64, distanceKm float64, vrMS float64) {
|
|
||||||
wx := mPerDegLon(latC)
|
|
||||||
wy := mPerDegLat
|
|
||||||
dx := (lonS - lonC) * wx // 东向米
|
|
||||||
dy := (latS - latC) * wy // 北向米
|
|
||||||
D := math.Hypot(dx, dy)
|
|
||||||
if D == 0 {
|
|
||||||
return true, 0, 0, speedMS
|
|
||||||
}
|
|
||||||
// 云→站方位角(北=0,顺时针)
|
|
||||||
theta := math.Atan2(dx, dy) * 180 / math.Pi
|
|
||||||
if theta < 0 {
|
|
||||||
theta += 360
|
|
||||||
}
|
|
||||||
beta := mathMod(dirToDeg, 360.0)
|
|
||||||
delta := (beta - theta) * math.Pi / 180.0
|
|
||||||
vr := speedMS * math.Cos(delta) // 指向站点的径向速度
|
|
||||||
if vr <= 0 {
|
|
||||||
return false, -1, D / 1000.0, vr
|
|
||||||
}
|
|
||||||
etaSec := D / vr
|
|
||||||
return true, etaSec / 60.0, D / 1000.0, vr
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为每个云团质心取一次风,构造 candidates 与 per-cluster 分析
|
|
||||||
type ClusterAnal struct {
|
|
||||||
ClusterID int `json:"cluster_id"`
|
|
||||||
Lon float64 `json:"lon"`
|
|
||||||
Lat float64 `json:"lat"`
|
|
||||||
AreaPx int `json:"area_px"`
|
|
||||||
MaxDBZ float64 `json:"max_dbz"`
|
|
||||||
SpeedMS float64 `json:"speed_ms"`
|
|
||||||
DirToDeg float64 `json:"dir_to_deg"`
|
|
||||||
U float64 `json:"u_east_ms"`
|
|
||||||
V float64 `json:"v_north_ms"`
|
|
||||||
Coming bool `json:"coming"`
|
|
||||||
ETAMin float64 `json:"eta_min,omitempty"`
|
|
||||||
DistanceKm float64 `json:"distance_km"`
|
|
||||||
LateralKm float64 `json:"lateral_km"`
|
|
||||||
RCloudKm float64 `json:"r_cloud_km"`
|
|
||||||
VrMS float64 `json:"vr_ms"`
|
|
||||||
}
|
|
||||||
analyses := []ClusterAnal{}
|
|
||||||
|
|
||||||
// 等效云半径与侧向距离仅用于展示(不再作为判定条件)
|
|
||||||
cellDims := func(lat float64) (float64, float64) { // 每像素米宽/米高
|
|
||||||
return meta.ResDeg * mPerDegLon(lat), meta.ResDeg * mPerDegLat
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cl := range meta.Clusters {
|
|
||||||
// 取质心风
|
|
||||||
speed, dirFrom, tempC, rh, pPa, err := rf.FetchCaiyunRealtime(cl.Lon, cl.Lat)
|
|
||||||
q := rf.QueryCandidate{ClusterID: cl.ID, Role: "center", Lon: cl.Lon, Lat: cl.Lat}
|
|
||||||
co := CandOut{QueryCandidate: q}
|
|
||||||
if err != nil {
|
|
||||||
co.Error = err.Error()
|
|
||||||
outs = append(outs, co)
|
|
||||||
// 即便取风失败,也继续下一个云团
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dirTo := mathMod(dirFrom+180.0, 360.0)
|
|
||||||
u, v := windVectorUV(speed, dirTo)
|
|
||||||
pHpa := pPa / 100.0
|
|
||||||
co.Wind = &Wind{Speed: speed, DirFrom: dirFrom, DirTo: dirTo, U: u, V: v, TempC: tempC, RH: rh, PressureHpa: pHpa}
|
|
||||||
outs = append(outs, co)
|
|
||||||
|
|
||||||
// 极坐标法靠近/ETA(到站点)
|
|
||||||
coming, etaMin, distKm, vr := approachETA(cl.Lon, cl.Lat, speed, dirTo, stationLon, stationLat)
|
|
||||||
|
|
||||||
// 展示参数:侧向距与等效半径
|
|
||||||
// 侧向距 = 距离向量对速度方向单位向量的叉积绝对值
|
|
||||||
wx, wy := mPerDegLon(cl.Lat), mPerDegLat
|
|
||||||
px := (stationLon - cl.Lon) * wx
|
|
||||||
py := (stationLat - cl.Lat) * wy
|
|
||||||
vnorm := math.Hypot(u, v)
|
|
||||||
lateral := 0.0
|
|
||||||
if vnorm > 0 {
|
|
||||||
vx, vy := u/vnorm, v/vnorm
|
|
||||||
lateral = math.Abs(px*vy - py*vx)
|
|
||||||
}
|
|
||||||
cw, ch := cellDims(cl.Lat)
|
|
||||||
areaM2 := float64(cl.AreaPx) * cw * ch
|
|
||||||
rCloud := math.Sqrt(areaM2 / math.Pi)
|
|
||||||
|
|
||||||
analyses = append(analyses, ClusterAnal{
|
|
||||||
ClusterID: cl.ID,
|
|
||||||
Lon: cl.Lon, Lat: cl.Lat,
|
|
||||||
AreaPx: cl.AreaPx, MaxDBZ: cl.MaxDBZ,
|
|
||||||
SpeedMS: speed, DirToDeg: dirTo, U: u, V: v,
|
|
||||||
Coming: coming, ETAMin: round2(etaMin),
|
|
||||||
DistanceKm: round2(distKm), LateralKm: round2(lateral / 1000.0), RCloudKm: round2(rCloud / 1000.0),
|
|
||||||
VrMS: round2(vr),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"station": gin.H{"lon": stationLon, "lat": stationLat},
|
|
||||||
"params": meta.QueryParams, // 兼容保留
|
|
||||||
"candidates": outs,
|
|
||||||
"clusters": analyses,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func windVectorUV(speed, dirTo float64) (u, v float64) {
|
|
||||||
// dirTo: 0=north, 90=east
|
|
||||||
rad := dirTo * math.Pi / 180.0
|
|
||||||
u = speed * math.Sin(rad)
|
|
||||||
v = speed * math.Cos(rad)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func uvToDirTo(u, v float64) float64 {
|
|
||||||
// inverse of above
|
|
||||||
rad := math.Atan2(u, v) // atan2(y,x) but here y=u (east), x=v (north)
|
|
||||||
deg := rad * 180.0 / math.Pi
|
|
||||||
if deg < 0 {
|
|
||||||
deg += 360.0
|
|
||||||
}
|
|
||||||
return deg
|
|
||||||
}
|
|
||||||
|
|
||||||
func mathMod(a, m float64) float64 { // positive modulo
|
|
||||||
r := math.Mod(a, m)
|
|
||||||
if r < 0 {
|
|
||||||
r += m
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func round2(x float64) float64 { return math.Round(x*100.0) / 100.0 }
|
|
||||||
|
|||||||
287
internal/server/radar_api.go
Normal file
287
internal/server/radar_api.go
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/binary"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
"weatherstation/internal/database"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type radarTileRecord 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 radarTileResponse 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"` // null 表示无效值
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLatestRadarTile(db *sql.DB, z, y, x int) (*radarTileRecord, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data
|
||||||
|
FROM radar_tiles
|
||||||
|
WHERE z=$1 AND y=$2 AND x=$3
|
||||||
|
ORDER BY dt DESC
|
||||||
|
LIMIT 1`
|
||||||
|
var r radarTileRecord
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// latestRadarTileHandler 返回指定 z/y/x 的最新瓦片,包含栅格 dBZ 值及元数据
|
||||||
|
func latestRadarTileHandler(c *gin.Context) {
|
||||||
|
// 固定默认 7/40/102,可通过查询参数覆盖
|
||||||
|
z := parseIntDefault(c.Query("z"), 7)
|
||||||
|
y := parseIntDefault(c.Query("y"), 40)
|
||||||
|
x := parseIntDefault(c.Query("x"), 102)
|
||||||
|
|
||||||
|
rec, err := getLatestRadarTile(database.GetDB(), z, y, x)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "未找到雷达瓦片"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解码大端 int16 → dBZ (raw/10). >=32766 视为无效(null)
|
||||||
|
w, h := rec.Width, rec.Height
|
||||||
|
vals := make([][]*float64, h)
|
||||||
|
// 每行 256 单元,每单元 2 字节
|
||||||
|
if len(rec.Data) < w*h*2 {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "数据长度异常"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
off := 0
|
||||||
|
for row := 0; row < h; row++ {
|
||||||
|
rowVals := make([]*float64, w)
|
||||||
|
for col := 0; col < w; col++ {
|
||||||
|
v := int16(binary.BigEndian.Uint16(rec.Data[off : off+2]))
|
||||||
|
off += 2
|
||||||
|
if v >= 32766 {
|
||||||
|
rowVals[col] = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dbz := float64(v) / 10.0
|
||||||
|
// 限幅到 [0,75](大部分 CREF 标准范围),便于颜色映射
|
||||||
|
if dbz < 0 {
|
||||||
|
dbz = 0
|
||||||
|
} else if dbz > 75 {
|
||||||
|
dbz = 75
|
||||||
|
}
|
||||||
|
vv := dbz
|
||||||
|
rowVals[col] = &vv
|
||||||
|
}
|
||||||
|
vals[row] = rowVals
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := radarTileResponse{
|
||||||
|
DT: rec.DT.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 parseIntDefault(s string, def int) int {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
_, err := fmtSscanf(s, &n)
|
||||||
|
if err != nil || n == 0 || n == math.MinInt || n == math.MaxInt {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtSscanf is a tiny wrapper to avoid importing fmt only for Sscanf
|
||||||
|
func fmtSscanf(s string, n *int) (int, error) {
|
||||||
|
// naive fast parse
|
||||||
|
sign := 1
|
||||||
|
i := 0
|
||||||
|
if len(s) > 0 && (s[0] == '-' || s[0] == '+') {
|
||||||
|
if s[0] == '-' {
|
||||||
|
sign = -1
|
||||||
|
}
|
||||||
|
i = 1
|
||||||
|
}
|
||||||
|
val := 0
|
||||||
|
for ; i < len(s); i++ {
|
||||||
|
ch := s[i]
|
||||||
|
if ch < '0' || ch > '9' {
|
||||||
|
return 0, fmtError("invalid")
|
||||||
|
}
|
||||||
|
val = val*10 + int(ch-'0')
|
||||||
|
}
|
||||||
|
*n = sign * val
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fmtError string
|
||||||
|
|
||||||
|
func (e fmtError) Error() string { return string(e) }
|
||||||
|
|
||||||
|
// ---------------- Radar station realtime API ----------------
|
||||||
|
|
||||||
|
type radarWeatherRecord struct {
|
||||||
|
Alias string
|
||||||
|
Lat float64
|
||||||
|
Lon float64
|
||||||
|
DT time.Time
|
||||||
|
Temperature float64
|
||||||
|
Humidity float64
|
||||||
|
Cloudrate float64
|
||||||
|
Visibility float64
|
||||||
|
Dswrf float64
|
||||||
|
WindSpeed float64
|
||||||
|
WindDirection float64
|
||||||
|
Pressure float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type radarWeatherResponse struct {
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
DT string `json:"dt"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
Humidity float64 `json:"humidity"`
|
||||||
|
Cloudrate float64 `json:"cloudrate"`
|
||||||
|
Visibility float64 `json:"visibility"`
|
||||||
|
Dswrf float64 `json:"dswrf"`
|
||||||
|
WindSpeed float64 `json:"wind_speed"`
|
||||||
|
WindDirection float64 `json:"wind_direction"`
|
||||||
|
Pressure float64 `json:"pressure"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestRadarWeatherHandler(c *gin.Context) {
|
||||||
|
alias := c.Query("alias")
|
||||||
|
if alias == "" {
|
||||||
|
alias = "南宁雷达站"
|
||||||
|
}
|
||||||
|
const q = `
|
||||||
|
SELECT alias, lat, lon, dt,
|
||||||
|
temperature, humidity, cloudrate, visibility, dswrf,
|
||||||
|
wind_speed, wind_direction, pressure
|
||||||
|
FROM radar_weather
|
||||||
|
WHERE alias = $1
|
||||||
|
ORDER BY dt DESC
|
||||||
|
LIMIT 1`
|
||||||
|
var r radarWeatherRecord
|
||||||
|
err := database.GetDB().QueryRow(q, alias).Scan(
|
||||||
|
&r.Alias, &r.Lat, &r.Lon, &r.DT,
|
||||||
|
&r.Temperature, &r.Humidity, &r.Cloudrate, &r.Visibility, &r.Dswrf,
|
||||||
|
&r.WindSpeed, &r.WindDirection, &r.Pressure,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "未找到实时气象数据"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := radarWeatherResponse{
|
||||||
|
Alias: r.Alias,
|
||||||
|
Lat: r.Lat,
|
||||||
|
Lon: r.Lon,
|
||||||
|
DT: r.DT.Format("2006-01-02 15:04:05"),
|
||||||
|
Temperature: r.Temperature,
|
||||||
|
Humidity: r.Humidity,
|
||||||
|
Cloudrate: r.Cloudrate,
|
||||||
|
Visibility: r.Visibility,
|
||||||
|
Dswrf: r.Dswrf,
|
||||||
|
WindSpeed: r.WindSpeed,
|
||||||
|
WindDirection: r.WindDirection,
|
||||||
|
Pressure: r.Pressure,
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// radarWeatherAtHandler returns the radar weather record at the given dt (CST),
|
||||||
|
// rounded/exact 10-minute bucket time string "YYYY-MM-DD HH:MM:SS".
|
||||||
|
func radarWeatherAtHandler(c *gin.Context) {
|
||||||
|
alias := c.Query("alias")
|
||||||
|
if alias == "" {
|
||||||
|
alias = "南宁雷达站"
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
const q = `
|
||||||
|
SELECT alias, lat, lon, dt,
|
||||||
|
temperature, humidity, cloudrate, visibility, dswrf,
|
||||||
|
wind_speed, wind_direction, pressure
|
||||||
|
FROM radar_weather
|
||||||
|
WHERE alias = $1 AND dt = $2
|
||||||
|
LIMIT 1`
|
||||||
|
var r radarWeatherRecord
|
||||||
|
err = database.GetDB().QueryRow(q, alias, dt).Scan(
|
||||||
|
&r.Alias, &r.Lat, &r.Lon, &r.DT,
|
||||||
|
&r.Temperature, &r.Humidity, &r.Cloudrate, &r.Visibility, &r.Dswrf,
|
||||||
|
&r.WindSpeed, &r.WindDirection, &r.Pressure,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "未找到指定时间的实时气象数据"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := radarWeatherResponse{
|
||||||
|
Alias: r.Alias,
|
||||||
|
Lat: r.Lat,
|
||||||
|
Lon: r.Lon,
|
||||||
|
DT: r.DT.Format("2006-01-02 15:04:05"),
|
||||||
|
Temperature: r.Temperature,
|
||||||
|
Humidity: r.Humidity,
|
||||||
|
Cloudrate: r.Cloudrate,
|
||||||
|
Visibility: r.Visibility,
|
||||||
|
Dswrf: r.Dswrf,
|
||||||
|
WindSpeed: r.WindSpeed,
|
||||||
|
WindDirection: r.WindDirection,
|
||||||
|
Pressure: r.Pressure,
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
8
static/js/plotly-2.27.0.min.js
vendored
Normal file
8
static/js/plotly-2.27.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
15
templates/_header.html
Normal file
15
templates/_header.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{{ define "header" }}
|
||||||
|
<div class="header p-2 text-center border-b border-gray-200">
|
||||||
|
<div class="content-narrow mx-auto px-4 py-2 flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<h1 class="text-xl md:text-2xl font-semibold">{{ .Title }}</h1>
|
||||||
|
<nav class="text-sm flex items-center gap-3">
|
||||||
|
<a href="/" class="text-blue-600 hover:text-blue-700">首页</a>
|
||||||
|
<a href="/radar/nanning" class="text-blue-600 hover:text-blue-700">南宁雷达站</a>
|
||||||
|
<a href="/radar/guangzhou" class="text-blue-600 hover:text-blue-700">广州雷达站</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.content-narrow { max-width: 1200px; }
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@ -7,7 +7,6 @@
|
|||||||
<script src="/static/js/chart.js"></script>
|
<script src="/static/js/chart.js"></script>
|
||||||
<link rel="stylesheet" href="/static/css/ol.css">
|
<link rel="stylesheet" href="/static/css/ol.css">
|
||||||
<script src="/static/js/ol.js"></script>
|
<script src="/static/js/ol.js"></script>
|
||||||
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
|
||||||
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
@ -240,31 +239,6 @@
|
|||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Radar view image normalization */
|
|
||||||
.radar-grid .img-wrap {
|
|
||||||
position: relative;
|
|
||||||
width: 75%;
|
|
||||||
margin: 0 auto;
|
|
||||||
aspect-ratio: 4/3;
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.radar-grid .img-wrap { min-height: 220px; }
|
|
||||||
@media (min-width: 768px) { .radar-grid .img-wrap { min-height: 320px; } }
|
|
||||||
@media (min-width: 1024px) { .radar-grid .img-wrap { min-height: 360px; } }
|
|
||||||
.radar-grid .img-wrap img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-info {
|
.system-info {
|
||||||
background-color: #e9ecef;
|
background-color: #e9ecef;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@ -455,9 +429,7 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="{ showPastForecast: false, deviceModalOpen: false }" class="text-[14px] md:text-[15px]" x-init="window.addEventListener('close-device-modal', () => { deviceModalOpen = false })">
|
<body x-data="{ showPastForecast: false, deviceModalOpen: false }" class="text-[14px] md:text-[15px]" x-init="window.addEventListener('close-device-modal', () => { deviceModalOpen = false })">
|
||||||
<div class="header p-2 text-center border-b border-gray-200">
|
{{ template "header" . }}
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold p-7">{{.Title}}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="deviceModal" class="device-modal" x-show="deviceModalOpen" x-transition.opacity @click.self="deviceModalOpen=false">
|
<div id="deviceModal" class="device-modal" x-show="deviceModalOpen" x-transition.opacity @click.self="deviceModalOpen=false">
|
||||||
<div class="device-modal-content bg-white shadow-xl" x-transition.scale.duration.150ms>
|
<div class="device-modal-content bg-white shadow-xl" x-transition.scale.duration.150ms>
|
||||||
@ -479,20 +451,10 @@
|
|||||||
|
|
||||||
<div class="container content-narrow py-5">
|
<div class="container content-narrow py-5">
|
||||||
<div class="system-info bg-gray-100 p-3 mb-5 rounded text-sm">
|
<div class="system-info bg-gray-100 p-3 mb-5 rounded text-sm">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<strong>在线设备: </strong> <span id="onlineDevices">{{.OnlineDevices}}</span> 个 |
|
||||||
<div>
|
<strong>总设备: </strong> <a href="#" id="showDeviceList" class="text-blue-600 hover:text-blue-700 underline-offset-2" @click.prevent="deviceModalOpen = true; window.WeatherApp.updateDeviceList(1)"><span id="wh65lpCount">0</span> 个</a>
|
||||||
<strong>在线设备: </strong> <span id="onlineDevices">{{.OnlineDevices}}</span> 个 |
|
|
||||||
<strong>总设备: </strong>
|
|
||||||
<a href="#" id="showDeviceList" class="text-blue-600 hover:text-blue-700 underline-offset-2" @click.prevent="deviceModalOpen = true; window.WeatherApp.updateDeviceList(1)"><span id="wh65lpCount">0</span> 个</a>
|
|
||||||
</div>
|
|
||||||
<nav class="flex items-center gap-2">
|
|
||||||
<a id="tab-station" href="#station" class="px-3 py-1 rounded text-sm font-medium bg-blue-600 text-white">气象站</a>
|
|
||||||
<a id="tab-radar" href="#radar" class="px-3 py-1 rounded text-sm text-blue-700 hover:bg-blue-50">南宁雷达</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="view-station">
|
|
||||||
<div class="controls flex flex-col gap-4 mb-5 p-4 border rounded bg-white">
|
<div class="controls flex flex-col gap-4 mb-5 p-4 border rounded bg-white">
|
||||||
<div class="control-row flex items-center gap-4 flex-wrap">
|
<div class="control-row flex items-center gap-4 flex-wrap">
|
||||||
<div class="station-input-group flex items-center gap-1">
|
<div class="station-input-group flex items-center gap-1">
|
||||||
@ -594,367 +556,11 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="view-radar" style="display: none;">
|
|
||||||
<div class="bg-white border border-gray-200 rounded p-4 text-gray-700 radar-grid">
|
|
||||||
<div id="radarInfo" class="text-sm mb-3">正在加载最新雷达数据...</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 mb-3 text-sm">
|
|
||||||
<button id="radar-tab-china" class="px-3 py-1 rounded bg-blue-600 text-white">中国</button>
|
|
||||||
<button id="radar-tab-huanan" class="px-3 py-1 rounded bg-gray-100 text-blue-700 hover:bg-blue-50">华南</button>
|
|
||||||
<button id="radar-tab-nanning" class="px-3 py-1 rounded bg-gray-100 text-blue-700 hover:bg-blue-50">南宁</button>
|
|
||||||
<button id="radar-tab-cma" class="px-3 py-1 rounded bg-gray-100 text-blue-700 hover:bg-blue-50">CMA</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="img-wrap">
|
|
||||||
<img id="radar-main-img" alt="radar" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="radar-heat-section" class="mt-4">
|
|
||||||
<div class="text-xs text-gray-500 mb-1">二维渲染(dBZ)</div>
|
|
||||||
<div class="w-full flex justify-center">
|
|
||||||
<div id="radar-heat-plot" style="width:75%;max-width:640px;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.TIANDITU_KEY = '{{.TiandituKey}}';
|
window.TIANDITU_KEY = '{{.TiandituKey}}';
|
||||||
</script>
|
</script>
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
function getViewFromHash() {
|
|
||||||
return (location.hash || '#station').replace('#','');
|
|
||||||
}
|
|
||||||
function setActive(view) {
|
|
||||||
var stationView = document.getElementById('view-station');
|
|
||||||
var radarView = document.getElementById('view-radar');
|
|
||||||
var tabStation = document.getElementById('tab-station');
|
|
||||||
var tabRadar = document.getElementById('tab-radar');
|
|
||||||
|
|
||||||
var activeClasses = ['bg-blue-600', 'text-white', 'font-medium'];
|
|
||||||
var inactiveClasses = ['text-blue-700', 'hover:bg-blue-50'];
|
|
||||||
|
|
||||||
if (view === 'radar') {
|
|
||||||
stationView.style.display = 'none';
|
|
||||||
radarView.style.display = 'block';
|
|
||||||
tabStation.classList.remove.apply(tabStation.classList, activeClasses);
|
|
||||||
inactiveClasses.forEach(c=>tabStation.classList.add(c));
|
|
||||||
inactiveClasses.forEach(c=>tabRadar.classList.remove(c));
|
|
||||||
activeClasses.forEach(c=>tabRadar.classList.add(c));
|
|
||||||
} else {
|
|
||||||
stationView.style.display = 'block';
|
|
||||||
radarView.style.display = 'none';
|
|
||||||
tabRadar.classList.remove.apply(tabRadar.classList, activeClasses);
|
|
||||||
inactiveClasses.forEach(c=>tabRadar.classList.add(c));
|
|
||||||
inactiveClasses.forEach(c=>tabStation.classList.remove(c));
|
|
||||||
activeClasses.forEach(c=>tabStation.classList.add(c));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function initTabs() {
|
|
||||||
var view = getViewFromHash();
|
|
||||||
setActive(view);
|
|
||||||
if (view === 'radar') { loadRadarLatest(); loadPlotGrid(); }
|
|
||||||
}
|
|
||||||
window.addEventListener('hashchange', initTabs);
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var ts = document.getElementById('tab-station');
|
|
||||||
var tr = document.getElementById('tab-radar');
|
|
||||||
if (ts) ts.addEventListener('click', function(e){ e.preventDefault(); location.hash = '#station';});
|
|
||||||
if (tr) tr.addEventListener('click', function(e){ e.preventDefault(); location.hash = '#radar';});
|
|
||||||
initTabs();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadRadarLatest() {
|
|
||||||
var infoEl = document.getElementById('radarInfo');
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/radar/latest');
|
|
||||||
if (!res.ok) throw new Error('no data');
|
|
||||||
const data = await res.json();
|
|
||||||
const meta = data.meta || {};
|
|
||||||
const images = data.images || {};
|
|
||||||
var ts = meta.timestamp_local || '未知';
|
|
||||||
var z = meta.z != null ? meta.z : null;
|
|
||||||
var y = meta.y != null ? meta.y : null;
|
|
||||||
var x = meta.x != null ? meta.x : null;
|
|
||||||
var b = meta.bounds || {};
|
|
||||||
var west = (typeof b.west === 'number') ? b.west : null;
|
|
||||||
var south = (typeof b.south === 'number') ? b.south : null;
|
|
||||||
var east = (typeof b.east === 'number') ? b.east : null;
|
|
||||||
var north = (typeof b.north === 'number') ? b.north : null;
|
|
||||||
var html = '<div class="text-sm">'
|
|
||||||
+ '<div>时间(南宁):<span class="font-medium">'+ts+'</span></div>'
|
|
||||||
+ (z!=null && y!=null && x!=null ? ('<div>Tile z/y/x:<span class="font-mono">'+z+'/'+y+'/'+x+'</span></div>') : '')
|
|
||||||
+ ((west!=null && south!=null && east!=null && north!=null) ? ('<div>CMA BIN 边界:<span class="font-mono">['+west.toFixed(4)+', '+south.toFixed(4)+'] ~ ['+east.toFixed(4)+', '+north.toFixed(4)+']</span></div>') : '')
|
|
||||||
+ '</div>';
|
|
||||||
infoEl.innerHTML = html;
|
|
||||||
window.RadarLatestImages = images;
|
|
||||||
setRadarImage('china');
|
|
||||||
bindRadarTabs();
|
|
||||||
} catch (e) {
|
|
||||||
infoEl.textContent = '暂无最新雷达数据';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindRadarTabs() {
|
|
||||||
var ids = ['china','huanan','nanning','cma'];
|
|
||||||
ids.forEach(function(k){
|
|
||||||
var el = document.getElementById('radar-tab-' + k);
|
|
||||||
if (el) el.onclick = function(){ setRadarImage(k); };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setRadarImage(kind) {
|
|
||||||
var images = window.RadarLatestImages || {};
|
|
||||||
var url = images[kind];
|
|
||||||
var img = document.getElementById('radar-main-img');
|
|
||||||
if (url) { img.src = url + '?t=' + Date.now(); }
|
|
||||||
// toggle active styles
|
|
||||||
var ids = ['china','huanan','nanning','cma'];
|
|
||||||
ids.forEach(function(k){
|
|
||||||
var el = document.getElementById('radar-tab-' + k);
|
|
||||||
if (!el) return;
|
|
||||||
if (k === kind) {
|
|
||||||
el.classList.add('bg-blue-600','text-white');
|
|
||||||
el.classList.remove('bg-gray-100','text-blue-700');
|
|
||||||
} else {
|
|
||||||
el.classList.remove('bg-blue-600','text-white');
|
|
||||||
el.classList.add('bg-gray-100','text-blue-700');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPlotGrid(){
|
|
||||||
const res = await fetch('/api/radar/latest/grid');
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
window.RadarLatestGrid = data;
|
|
||||||
renderPlotlyHeat(data);
|
|
||||||
renderClustersPanel();
|
|
||||||
//renderMethodNote();
|
|
||||||
renderWindResults();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPlotlyHeat(payload){
|
|
||||||
// Preserve real dBZ values for z (so hover/scale show dBZ)
|
|
||||||
var z = (payload.grid || []).slice();
|
|
||||||
var colors = [
|
|
||||||
'#0000F6','#01A0F6','#00ECEC','#01FF00','#00C800',
|
|
||||||
'#019000','#FFFF00','#E7C000','#FF9000','#FF0000',
|
|
||||||
'#D60000','#C00000','#FF00F0','#780084','#AD90F0'
|
|
||||||
];
|
|
||||||
// Build step-like colorscale over dBZ domain [0, 5*(n-1)]
|
|
||||||
var zmin = 0;
|
|
||||||
var zmax = 5 * (colors.length - 1); // 70 for 15 colors
|
|
||||||
var colorscale = [];
|
|
||||||
for (var i=0;i<colors.length;i++){
|
|
||||||
var lo = (i*5)/zmax;
|
|
||||||
var hi = ((i+1)*5)/zmax;
|
|
||||||
if (i === colors.length-1) hi = 1.0;
|
|
||||||
// duplicate stops to create discrete bands
|
|
||||||
colorscale.push([lo, colors[i]]);
|
|
||||||
colorscale.push([hi, colors[i]]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute lon/lat arrays from bounds + res for hover/axes
|
|
||||||
var w = (payload.bounds && typeof payload.bounds.west === 'number') ? payload.bounds.west : 0;
|
|
||||||
var s = (payload.bounds && typeof payload.bounds.south === 'number') ? payload.bounds.south : 0;
|
|
||||||
var e = (payload.bounds && typeof payload.bounds.east === 'number') ? payload.bounds.east : 256;
|
|
||||||
var n = (payload.bounds && typeof payload.bounds.north === 'number') ? payload.bounds.north : 256;
|
|
||||||
var res = (typeof payload.res_deg === 'number' && payload.res_deg > 0) ? payload.res_deg : ((e - w) / 256.0);
|
|
||||||
// Use pixel centers for coordinates
|
|
||||||
var xs = new Array(256);
|
|
||||||
var ys = new Array(256);
|
|
||||||
for (var xi = 0; xi < 256; xi++) { xs[xi] = w + (xi + 0.5) * res; }
|
|
||||||
for (var yi = 0; yi < 256; yi++) { ys[yi] = s + (yi + 0.5) * res; }
|
|
||||||
|
|
||||||
// Build customdata to carry pixel (x,y) indices for hover
|
|
||||||
var cd = new Array(256);
|
|
||||||
for (var r = 0; r < 256; r++) {
|
|
||||||
var row = new Array(256);
|
|
||||||
for (var c = 0; c < 256; c++) {
|
|
||||||
row[c] = [c, r];
|
|
||||||
}
|
|
||||||
cd[r] = row;
|
|
||||||
}
|
|
||||||
|
|
||||||
var trace = {
|
|
||||||
z: z,
|
|
||||||
x: xs,
|
|
||||||
y: ys,
|
|
||||||
type: 'heatmap',
|
|
||||||
colorscale: colorscale,
|
|
||||||
colorbar: { title: 'dBZ', thickness: 18 },
|
|
||||||
zauto: false,
|
|
||||||
zmin: zmin,
|
|
||||||
zmax: zmax,
|
|
||||||
zsmooth: false,
|
|
||||||
customdata: cd,
|
|
||||||
hovertemplate: 'x=%{customdata[0]}, y=%{customdata[1]}<br>lon=%{x:.6f}, lat=%{y:.6f}<br>dBZ=%{z:.1f}<extra></extra>'
|
|
||||||
};
|
|
||||||
var box = document.getElementById('radar-heat-plot');
|
|
||||||
var size = Math.max(220, Math.min(520, Math.floor((box.clientWidth || 520))));
|
|
||||||
var cbx = 60; // approximate space for colorbar + padding
|
|
||||||
var layout = {
|
|
||||||
margin: {l:50, r:10, t:10, b:40},
|
|
||||||
xaxis: {
|
|
||||||
tickformat: '.3f',
|
|
||||||
range: [w, e]
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
tickformat: '.3f',
|
|
||||||
range: [s, n]
|
|
||||||
},
|
|
||||||
width: size + cbx,
|
|
||||||
height: size,
|
|
||||||
};
|
|
||||||
var config = {displayModeBar:false, responsive:true};
|
|
||||||
Plotly.newPlot('radar-heat-plot', [trace], layout, config).then(function(){
|
|
||||||
window.addEventListener('resize', function(){
|
|
||||||
var s2 = Math.max(220, Math.min(520, Math.floor((box.clientWidth || 520))));
|
|
||||||
Plotly.relayout('radar-heat-plot', {width: s2 + cbx, height: s2});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderClustersPanel(){
|
|
||||||
// fetch meta to read clusters
|
|
||||||
fetch('/api/radar/latest').then(r=>r.json()).then(function(resp){
|
|
||||||
var meta = resp.meta || {};
|
|
||||||
var clusters = meta.clusters || [];
|
|
||||||
var host = '/radar/latest/';
|
|
||||||
var containerId = 'radar-clusters';
|
|
||||||
var parent = document.getElementById(containerId);
|
|
||||||
if (!parent) {
|
|
||||||
var sec = document.createElement('div');
|
|
||||||
sec.id = containerId;
|
|
||||||
sec.className = 'mt-4';
|
|
||||||
var root = document.getElementById('view-radar').querySelector('.radar-grid');
|
|
||||||
root.appendChild(sec);
|
|
||||||
parent = sec;
|
|
||||||
}
|
|
||||||
if (!clusters.length) { parent.innerHTML = '<div class="text-sm text-gray-500">暂无 >=40 dBZ 云团</div>'; return; }
|
|
||||||
var html = '<div class="text-sm text-gray-700 mb-2">云团(dBZ≥40)共 ' + clusters.length + ' 个</div>';
|
|
||||||
html += '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">';
|
|
||||||
clusters.forEach(function(cl){
|
|
||||||
var png = cl.png ? (host + cl.png) : '';
|
|
||||||
html += '<div class="border border-gray-200 rounded p-2">';
|
|
||||||
if (png) {
|
|
||||||
html += '<div class="mb-2 flex items-center justify-center" style="background:#fafafa">'
|
|
||||||
+ '<img src="'+png+'" style="image-rendering: pixelated; max-width: 100%; max-height: 120px;" />'
|
|
||||||
+ '</div>';
|
|
||||||
}
|
|
||||||
html += '<div class="text-xs text-gray-600">'
|
|
||||||
+ 'ID: '+cl.id+' | 像元: '+cl.area_px+'<br/>'
|
|
||||||
+ '质心: '+cl.lon.toFixed(4)+', '+cl.lat.toFixed(4)+'<br/>'
|
|
||||||
+ 'dBZ: max '+cl.max_dbz.toFixed(1)+' / avg '+cl.avg_dbz.toFixed(1)
|
|
||||||
+ '</div>';
|
|
||||||
// 极坐标法:不再展示采样点列表(仅使用质心)
|
|
||||||
html += '</div>';
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
parent.innerHTML = html;
|
|
||||||
}).catch(function(){ /* ignore */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMethodNote(){
|
|
||||||
var containerId = 'radar-wind-query';
|
|
||||||
var parent = document.getElementById(containerId);
|
|
||||||
if (!parent) {
|
|
||||||
var sec = document.createElement('div');
|
|
||||||
sec.id = containerId;
|
|
||||||
sec.className = 'mt-4';
|
|
||||||
var root = document.getElementById('view-radar').querySelector('.radar-grid');
|
|
||||||
root.appendChild(sec);
|
|
||||||
parent = sec;
|
|
||||||
}
|
|
||||||
var html = '<div class="text-sm text-gray-700 mb-1">方法</div>';
|
|
||||||
html += '<div class="text-xs text-gray-600">极坐标(质心单点):使用云团质心处彩云风,计算与站点的径向分量与 ETA。</div>';
|
|
||||||
parent.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderWindResults(){
|
|
||||||
fetch('/api/radar/latest/wind').then(r=>r.json()).then(function(resp){
|
|
||||||
var station = resp.station || {};
|
|
||||||
var cands = resp.candidates || [];
|
|
||||||
var clusters = resp.clusters || [];
|
|
||||||
var containerId = 'radar-wind-results';
|
|
||||||
var parent = document.getElementById(containerId);
|
|
||||||
if (!parent) {
|
|
||||||
var sec = document.createElement('div');
|
|
||||||
sec.id = containerId;
|
|
||||||
sec.className = 'mt-4';
|
|
||||||
var root = document.getElementById('view-radar').querySelector('.radar-grid');
|
|
||||||
root.appendChild(sec);
|
|
||||||
parent = sec;
|
|
||||||
}
|
|
||||||
var html = '<div class="text-sm text-gray-700 mb-2">10m</div>';
|
|
||||||
// cluster summary
|
|
||||||
if (clusters.length) {
|
|
||||||
html += '<div class="text-xs text-gray-700 mb-2">云团:</div>';
|
|
||||||
html += '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mb-3">';
|
|
||||||
clusters.forEach(function(cl){
|
|
||||||
html += '<div class="border border-gray-200 rounded p-2 text-xs text-gray-700">'
|
|
||||||
+ 'ID '+cl.cluster_id+' | 距离 '+(cl.distance_km||0).toFixed(1)+' km<br/>'
|
|
||||||
+ '风 '+(cl.speed_ms||0).toFixed(1)+' m/s, 去向 '+(cl.dir_to_deg||0).toFixed(0)+'°<br/>'
|
|
||||||
+ (cl.coming?('<span class="text-green-700">朝向</span>, ETA '+(cl.eta_min||0).toFixed(1)+' 分钟'):'<span class="text-gray-500">非朝向</span>')
|
|
||||||
+ '</div>';
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
// 朝向云团:极坐标计算明细列表
|
|
||||||
var candByCluster = {};
|
|
||||||
(cands||[]).forEach(function(co){ candByCluster[co.cluster_id] = co; });
|
|
||||||
var comings = (clusters||[]).filter(function(cl){ return cl.coming; });
|
|
||||||
if (comings.length) {
|
|
||||||
html += '<div class="text-xs text-gray-700 mb-2">朝向云团</div>';
|
|
||||||
comings.forEach(function(cl){
|
|
||||||
var co = candByCluster[cl.cluster_id] || {};
|
|
||||||
var w = co.wind || {};
|
|
||||||
var speed = (w.speed_ms!=null)? w.speed_ms : (cl.speed_ms||0);
|
|
||||||
var dirFrom = (w.dir_from_deg!=null)? w.dir_from_deg : null;
|
|
||||||
var dirTo = (w.dir_to_deg!=null)? w.dir_to_deg : (cl.dir_to_deg||0);
|
|
||||||
// 计算几何与极坐标过程(前端复算,便于展示公式与数值)
|
|
||||||
var mPerDegLat = 111320.0;
|
|
||||||
var mPerDegLon = 111320.0 * Math.cos((cl.lat||0) * Math.PI/180.0);
|
|
||||||
var dx = ((station.lon||0) - (cl.lon||0)) * mPerDegLon; // 东向
|
|
||||||
var dy = ((station.lat||0) - (cl.lat||0)) * mPerDegLat; // 北向
|
|
||||||
var D = Math.hypot(dx, dy); // m
|
|
||||||
var theta = Math.atan2(dx, dy) * 180/Math.PI; // 北=0, 顺时针
|
|
||||||
if (theta < 0) theta += 360;
|
|
||||||
var delta = dirTo - theta; // deg
|
|
||||||
// wrap 到 [-180,180]
|
|
||||||
delta = ((delta + 540) % 360) - 180;
|
|
||||||
var vr = speed * Math.cos(delta * Math.PI/180.0); // m/s(指向站点为正)
|
|
||||||
var etaMin = (vr>0) ? (D/vr/60.0) : null;
|
|
||||||
var code = ''
|
|
||||||
+ 'station = ('+(station.lon||0).toFixed(6)+', '+(station.lat||0).toFixed(6)+')\n'
|
|
||||||
+ 'centroid = ('+(cl.lon||0).toFixed(6)+', '+(cl.lat||0).toFixed(6)+')\n'
|
|
||||||
+ 'speed = '+speed.toFixed(2)+' m/s\n'
|
|
||||||
+ (dirFrom!=null?('dir_from = '+dirFrom.toFixed(0)+'°\n'):'')
|
|
||||||
+ 'dir_to = '+dirTo.toFixed(0)+'°\n'
|
|
||||||
+ 'dx = (lonS-lonC) * 111320*cos(latC) = '+dx.toFixed(1)+' m\n'
|
|
||||||
+ 'dy = (latS-latC) * 111320 = '+dy.toFixed(1)+' m\n'
|
|
||||||
+ 'D = hypot(dx,dy) = '+(D/1000.0).toFixed(2)+' km\n'
|
|
||||||
+ 'theta = atan2(dx,dy) = '+theta.toFixed(1)+'°\n'
|
|
||||||
+ 'delta = dir_to - theta = '+delta.toFixed(1)+'°\n'
|
|
||||||
+ 'vr = speed * cos(delta) = '+vr.toFixed(2)+' m/s\n'
|
|
||||||
+ (etaMin!=null?('ETA = D/vr = '+etaMin.toFixed(1)+' min\n'):'ETA = N/A (vr<=0)\n');
|
|
||||||
html += '<div class="mb-3 border border-green-200 rounded p-2 bg-green-50">'
|
|
||||||
+ '<div class="text-xs text-green-800">ID '+cl.cluster_id+' | 质心: '+cl.lon.toFixed(4)+', '+cl.lat.toFixed(4)+'</div>'
|
|
||||||
+ '<pre class="mt-1 text-[11px] leading-4 text-gray-800 overflow-x-auto">'+code.replace(/</g,'<')+'</pre>'
|
|
||||||
+ '</div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 极坐标法:省略采样点明细表,仅保留汇总与朝向明细
|
|
||||||
parent.innerHTML = html;
|
|
||||||
}).catch(function(){});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<script defer src="/static/js/alpinejs.min.js"></script>
|
<script defer src="/static/js/alpinejs.min.js"></script>
|
||||||
<script src="/static/js/utils.js"></script>
|
<script src="/static/js/utils.js"></script>
|
||||||
<script src="/static/js/weather-app.js"></script>
|
<script src="/static/js/weather-app.js"></script>
|
||||||
|
|||||||
271
templates/radar_guangzhou.html
Normal file
271
templates/radar_guangzhou.html
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ .Title }}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||||||
|
<script src="/static/js/plotly-2.27.0.min.js"></script>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 0; }
|
||||||
|
.content-narrow { width: 86%; max-width: 1200px; margin: 0 auto; }
|
||||||
|
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 20px; }
|
||||||
|
.plot-box { width: clamp(320px, 80vw, 720px); margin: 0 auto; aspect-ratio: 1 / 1; overflow: hidden; }
|
||||||
|
@supports not (aspect-ratio: 1 / 1) { .plot-box { height: 520px; } }
|
||||||
|
</style>
|
||||||
|
<!-- 广州雷达:lat=23.146400, lon=113.341200;瓦片固定 7/40/104 -->
|
||||||
|
<!-- 与南宁页一致,默认叠加扇形覆盖,无开关、无图例项 -->
|
||||||
|
<!-- 注意:需要后端入库 radar_tiles 与 radar_weather(广州)才有数据 -->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{ template "header" . }}
|
||||||
|
|
||||||
|
<div class="content-narrow p-4">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="text-lg font-semibold mb-2">最新 7/40/104 瓦片信息</div>
|
||||||
|
<div class="text-sm space-y-1">
|
||||||
|
<div>时间:<span id="dt" class="font-mono"></span></div>
|
||||||
|
<div>索引:z=<span id="z"></span> / y=<span id="y"></span> / x=<span id="x"></span></div>
|
||||||
|
<div>尺寸:<span id="size"></span></div>
|
||||||
|
<div>边界:W=<span id="west"></span>,S=<span id="south"></span>,E=<span id="east"></span>,N=<span id="north"></span></div>
|
||||||
|
<div>分辨率(度/像素):<span id="res"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="text-lg font-semibold mb-2">最新雷达站气象(广州雷达站)</div>
|
||||||
|
<div id="rtInfo" class="text-sm grid grid-cols-2 gap-y-1 gap-x-6">
|
||||||
|
<div>站点:<span id="rt_alias"></span></div>
|
||||||
|
<div>位置:<span id="rt_lat"></span>,<span id="rt_lon"></span></div>
|
||||||
|
<div>时间:<span id="rt_dt" class="font-mono"></span></div>
|
||||||
|
<div>温度:<span id="rt_t"></span> ℃</div>
|
||||||
|
<div>湿度:<span id="rt_h"></span></div>
|
||||||
|
<div>云量:<span id="rt_c"></span></div>
|
||||||
|
<div>能见度:<span id="rt_vis"></span> km</div>
|
||||||
|
<div>下行短波:<span id="rt_dswrf"></span> W/m²</div>
|
||||||
|
<div>风速:<span id="rt_ws"></span> m/s</div>
|
||||||
|
<div>风向:<span id="rt_wd"></span> °</div>
|
||||||
|
<div>气压:<span id="rt_p"></span> Pa</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="text-lg font-semibold mb-2">未来 3H 降雨评估(按来风±30°扇形)</div>
|
||||||
|
<div id="sectorInfo" class="text-sm">
|
||||||
|
<div id="sectorStatus">计算中…</div>
|
||||||
|
<div id="sectorDetail" class="mt-1 hidden">
|
||||||
|
最近距离:<span id="sectorDist"></span> km;预计到达时间:<span id="sectorETA"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="width:100%;">
|
||||||
|
<div class="text-lg font-semibold mb-2">雷达组合反射率</div>
|
||||||
|
<div id="radarPlot" class="plot-box"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4" style="width:100%;">
|
||||||
|
<div class="text-lg font-semibold mb-2">正方形裁减区域</div>
|
||||||
|
<div id="squarePlot" class="plot-box"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ST_ALIAS = '广州雷达站';
|
||||||
|
const ST_LAT = 23.146400, ST_LON = 113.341200;
|
||||||
|
const TILE_Z = 7, TILE_Y = 40, TILE_X = 104;
|
||||||
|
|
||||||
|
let gTileValues = null, gXs = null, gYs = null;
|
||||||
|
let gWindFromDeg = null, gWindSpeedMS = null;
|
||||||
|
|
||||||
|
function toRad(d){ return d * Math.PI / 180; }
|
||||||
|
function toDeg(r){ return r * 180 / Math.PI; }
|
||||||
|
function haversine(lat1, lon1, lat2, lon2){
|
||||||
|
const R = 6371000; const dLat = toRad(lat2-lat1); const dLon = toRad(lon2-lon1);
|
||||||
|
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2;
|
||||||
|
const c = 2*Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); return R*c;
|
||||||
|
}
|
||||||
|
function bearingDeg(lat1, lon1, lat2, lon2){
|
||||||
|
const φ1=toRad(lat1), φ2=toRad(lat2), Δλ=toRad(lon2-lon1);
|
||||||
|
const y=Math.sin(Δλ)*Math.cos(φ2); const x=Math.cos(φ1)*Math.sin(φ2)-Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ);
|
||||||
|
return (toDeg(Math.atan2(y,x))+360)%360;
|
||||||
|
}
|
||||||
|
function angDiff(a,b){ let d=((a-b+540)%360)-180; return Math.abs(d); }
|
||||||
|
function destPoint(lat, lon, brgDeg, distM){
|
||||||
|
const R=6371000, δ=distM/R, θ=toRad(brgDeg), φ1=toRad(lat), λ1=toRad(lon);
|
||||||
|
const sinφ1=Math.sin(φ1), cosφ1=Math.cos(φ1), sinδ=Math.sin(δ), cosδ=Math.cos(δ);
|
||||||
|
const sinφ2=sinφ1*cosδ+cosφ1*sinδ*Math.cos(θ); const φ2=Math.asin(sinφ2);
|
||||||
|
const y=Math.sin(θ)*sinδ*cosφ1; const x=cosδ-sinφ1*sinφ2; const λ2=λ1+Math.atan2(y,x);
|
||||||
|
return { lat: toDeg(φ2), lon: ((toDeg(λ2)+540)%360)-180 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLatestTile(){
|
||||||
|
const res = await fetch(`/api/radar/latest?z=${TILE_Z}&y=${TILE_Y}&x=${TILE_X}`);
|
||||||
|
if(!res.ok) throw new Error('加载最新瓦片失败');
|
||||||
|
const t = await res.json();
|
||||||
|
const fmt5 = (n)=>Number(n).toFixed(5);
|
||||||
|
|
||||||
|
document.getElementById('dt').textContent = t.dt;
|
||||||
|
document.getElementById('z').textContent = t.z;
|
||||||
|
document.getElementById('y').textContent = t.y;
|
||||||
|
document.getElementById('x').textContent = t.x;
|
||||||
|
document.getElementById('size').textContent = `${t.width} × ${t.height}`;
|
||||||
|
document.getElementById('west').textContent = fmt5(t.west);
|
||||||
|
document.getElementById('south').textContent = fmt5(t.south);
|
||||||
|
document.getElementById('east').textContent = fmt5(t.east);
|
||||||
|
document.getElementById('north').textContent = fmt5(t.north);
|
||||||
|
document.getElementById('res').textContent = fmt5(t.res_deg);
|
||||||
|
|
||||||
|
// x/y 等角坐标
|
||||||
|
const w=t.width,h=t.height; gTileValues=t.values;
|
||||||
|
const xs=new Array(w), ys=new Array(h);
|
||||||
|
const stepX=(t.east-t.west)/w, stepY=(t.north-t.south)/h;
|
||||||
|
for(let i=0;i<w;i++){ xs[i]=t.west+(i+0.5)*stepX; }
|
||||||
|
for(let j=0;j<h;j++){ ys[j]=t.south+(j+0.5)*stepY; }
|
||||||
|
gXs=xs; gYs=ys;
|
||||||
|
|
||||||
|
// 色带
|
||||||
|
const colors=["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
|
||||||
|
const scale=[]; for(let i=0;i<colors.length;i++){ const tpos=colors.length===1?0:i/(colors.length-1); scale.push([tpos,colors[i]]); }
|
||||||
|
const zBins=[]; const custom=[];
|
||||||
|
for(let r=0;r<h;r++){
|
||||||
|
const row=gTileValues[r]; const rowBins=[], rowCustom=[];
|
||||||
|
for(let c=0;c<w;c++){
|
||||||
|
const val=row[c]; if(val==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||||
|
let dbz=Number(val); if(dbz<0)dbz=0; if(dbz>75)dbz=75; let bin=Math.floor(dbz/5); if(bin>=15)bin=14;
|
||||||
|
rowBins.push(bin); rowCustom.push([r,c,dbz]);
|
||||||
|
}
|
||||||
|
zBins.push(rowBins); custom.push(rowCustom);
|
||||||
|
}
|
||||||
|
const heatTrace={ type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom, colorscale:scale, zmin:0, zmax:14,
|
||||||
|
colorbar:{orientation:'h',x:0.5,y:-0.12,xanchor:'center',yanchor:'top',len:0.8,thickness:16,title:{text:'dBZ',side:'bottom'},
|
||||||
|
tickmode:'array',tickvals:Array.from({length:15},(_,i)=>i),ticktext:Array.from({length:15},(_,i)=>String(i*5))},
|
||||||
|
hovertemplate:'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>' };
|
||||||
|
const layout={ autosize:true, margin:{l:40,r:8,t:8,b:90}, xaxis:{title:'经度',tickformat:'.2f',zeroline:false,constrain:'domain',automargin:true},
|
||||||
|
yaxis:{title:{text:'纬度',standoff:12},tickformat:'.2f',zeroline:false,scaleanchor:'x',scaleratio:1,constrain:'domain',automargin:true} };
|
||||||
|
// 扇形覆盖
|
||||||
|
const data=[heatTrace];
|
||||||
|
if(gWindFromDeg!==null && gWindSpeedMS>0){
|
||||||
|
const half=30, samples=64, start=gWindFromDeg-half, end=gWindFromDeg+half, rangeM=gWindSpeedMS*3*3600;
|
||||||
|
const xsFan=[ST_LON], ysFan=[ST_LAT];
|
||||||
|
for(let i=0;i<=samples;i++){ const θ=start+(end-start)*(i/samples); const p=destPoint(ST_LAT,ST_LON,((θ%360)+360)%360,rangeM); xsFan.push(p.lon); ysFan.push(p.lat); }
|
||||||
|
xsFan.push(ST_LON); ysFan.push(ST_LAT);
|
||||||
|
data.push({ type:'scatter', mode:'lines', x:xsFan, y:ysFan, line:{color:'#FFFFFF',width:2,dash:'dash'}, fill:'toself', fillcolor:'rgba(255,255,255,0.18)', hoverinfo:'skip', showlegend:false });
|
||||||
|
}
|
||||||
|
const plotEl=document.getElementById('radarPlot');
|
||||||
|
Plotly.newPlot(plotEl, data, layout, {responsive:true, displayModeBar:false}).then(()=>{ const s=plotEl.clientWidth; Plotly.relayout(plotEl,{height:s}); });
|
||||||
|
window.addEventListener('resize',()=>{ const s=plotEl.clientWidth; Plotly.relayout(plotEl,{height:s}); Plotly.Plots.resize(plotEl); });
|
||||||
|
|
||||||
|
// 与瓦片时间对齐的10分钟气象
|
||||||
|
try{
|
||||||
|
const dt=new Date(t.dt.replace(/-/g,'/')); const ceil10=new Date(dt); const m=dt.getMinutes(); const up=(Math.floor(m/10)*10+10)%60; ceil10.setMinutes(up,0,0); if(up===0){ ceil10.setHours(dt.getHours()+1); }
|
||||||
|
const pad=(n)=>String(n).padStart(2,'0'); const dtStr=`${ceil10.getFullYear()}-${pad(ceil10.getMonth()+1)}-${pad(ceil10.getDate())} ${pad(ceil10.getHours())}:${pad(ceil10.getMinutes())}:00`;
|
||||||
|
await loadRealtimeAt(dtStr);
|
||||||
|
maybeCalcSector();
|
||||||
|
maybePlotSquare();
|
||||||
|
}catch(e){ await loadRealtimeLatest(); maybeCalcSector(); maybePlotSquare(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRealtimeLatest(){
|
||||||
|
const res=await fetch(`/api/radar/weather_latest?alias=${encodeURIComponent(ST_ALIAS)}`);
|
||||||
|
if(!res.ok) throw new Error('加载实时气象失败');
|
||||||
|
const r=await res.json(); fillRealtime(r);
|
||||||
|
}
|
||||||
|
async function loadRealtimeAt(dtStr){
|
||||||
|
const res=await fetch(`/api/radar/weather_at?alias=${encodeURIComponent(ST_ALIAS)}&dt=${encodeURIComponent(dtStr)}`);
|
||||||
|
if(!res.ok) throw new Error('加载指定时间气象失败');
|
||||||
|
const r=await res.json(); fillRealtime(r);
|
||||||
|
}
|
||||||
|
function fillRealtime(r){
|
||||||
|
const f2=(n)=>Number(n).toFixed(2), f4=(n)=>Number(n).toFixed(4);
|
||||||
|
document.getElementById('rt_alias').textContent=r.alias;
|
||||||
|
document.getElementById('rt_lat').textContent=f4(r.lat);
|
||||||
|
document.getElementById('rt_lon').textContent=f4(r.lon);
|
||||||
|
document.getElementById('rt_dt').textContent=r.dt;
|
||||||
|
document.getElementById('rt_t').textContent=f2(r.temperature);
|
||||||
|
document.getElementById('rt_h').textContent=f2(r.humidity);
|
||||||
|
document.getElementById('rt_c').textContent=f2(r.cloudrate);
|
||||||
|
document.getElementById('rt_vis').textContent=f2(r.visibility);
|
||||||
|
document.getElementById('rt_dswrf').textContent=f2(r.dswrf);
|
||||||
|
document.getElementById('rt_ws').textContent=f2(r.wind_speed);
|
||||||
|
document.getElementById('rt_wd').textContent=f2(r.wind_direction);
|
||||||
|
document.getElementById('rt_p').textContent=f2(r.pressure);
|
||||||
|
gWindFromDeg=Number(r.wind_direction); gWindSpeedMS=Number(r.wind_speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeCalcSector(){
|
||||||
|
try{
|
||||||
|
if(!gTileValues || !gXs || !gYs || gWindFromDeg===null || gWindSpeedMS===null) return;
|
||||||
|
const halfAngle=30; const rangeM=gWindSpeedMS*3*3600; let best=null;
|
||||||
|
const h=gTileValues.length, w=gTileValues[0].length;
|
||||||
|
for(let r=0;r<h;r++){
|
||||||
|
const lat=gYs[r]; for(let c=0;c<w;c++){
|
||||||
|
const val=gTileValues[r][c]; if(val==null) continue; const dbz=Number(val); if(!(dbz>=40)) continue;
|
||||||
|
const lon=gXs[c]; const dist=haversine(ST_LAT,ST_LON,lat,lon); if(dist>rangeM) continue;
|
||||||
|
const brg=bearingDeg(ST_LAT,ST_LON,lat,lon); if(angDiff(brg,gWindFromDeg)>halfAngle) continue;
|
||||||
|
if(!best || dist<best.dist){ best={dist,lat,lon,dbz}; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const statusEl=document.getElementById('sectorStatus'); const detailEl=document.getElementById('sectorDetail');
|
||||||
|
if(!best){ statusEl.textContent='无(≥40 dBZ)'; detailEl.classList.add('hidden'); }
|
||||||
|
else {
|
||||||
|
const etaSec=best.dist/gWindSpeedMS; const eta=new Date(Date.now()+etaSec*1000);
|
||||||
|
const pad=(n)=>String(n).padStart(2,'0'); const etaStr=`${eta.getFullYear()}-${pad(eta.getMonth()+1)}-${pad(eta.getDate())} ${pad(eta.getHours())}:${pad(eta.getMinutes())}`;
|
||||||
|
document.getElementById('sectorDist').textContent=(best.dist/1000).toFixed(1);
|
||||||
|
document.getElementById('sectorETA').textContent=etaStr;
|
||||||
|
statusEl.textContent='三小时内可能有降雨(≥40 dBZ )'; detailEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}catch(e){ document.getElementById('sectorStatus').textContent='风险评估计算失败:'+e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybePlotSquare(){
|
||||||
|
try{
|
||||||
|
if(!gTileValues || !gXs || !gYs || gWindSpeedMS===null) return;
|
||||||
|
const R=6371000; const rangeM=gWindSpeedMS*3*3600;
|
||||||
|
// 以站点为中心的正方形(3小时可达半径),作为“细节放大”裁剪窗口
|
||||||
|
const dLat=(rangeM/R)*(180/Math.PI);
|
||||||
|
const dLon=(rangeM/(R*Math.cos(toRad(ST_LAT))))*(180/Math.PI);
|
||||||
|
const latMin=ST_LAT-dLat, latMax=ST_LAT+dLat, lonMin=ST_LON-dLon, lonMax=ST_LON+dLon;
|
||||||
|
const xs=gXs, ys=gYs; const h=gTileValues.length,w=gTileValues[0].length;
|
||||||
|
const colors=["#0000FF","#00BFFF","#00FFFF","#7FFFD4","#7CFC00","#ADFF2F","#FFFF00","#FFD700","#FFA500","#FF8C00","#FF4500","#FF0000","#DC143C","#C71585","#8B008B"];
|
||||||
|
const scale=[]; for(let i=0;i<colors.length;i++){ const tpos=colors.length===1?0:i/(colors.length-1); scale.push([tpos,colors[i]]); }
|
||||||
|
const zBins=[], custom=[];
|
||||||
|
for(let r=0;r<h;r++){
|
||||||
|
const row=gTileValues[r]; const rowBins=[], rowCustom=[]; const lat=ys[r];
|
||||||
|
for(let c=0;c<w;c++){
|
||||||
|
const lon=xs[c]; if(lat<latMin||lat>latMax||lon<lonMin||lon>lonMax){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||||
|
const val=row[c]; if(val==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||||
|
let dbz=Number(val); if(dbz<0)dbz=0; if(dbz>75)dbz=75; let bin=Math.floor(dbz/5); if(bin>=15)bin=14;
|
||||||
|
rowBins.push(bin); rowCustom.push([r,c,dbz]);
|
||||||
|
}
|
||||||
|
zBins.push(rowBins); custom.push(rowCustom);
|
||||||
|
}
|
||||||
|
const data=[{ type:'heatmap', x:xs, y:ys, z:zBins, customdata:custom, colorscale:scale, zmin:0, zmax:14,
|
||||||
|
colorbar:{orientation:'h',x:0.5,y:-0.12,xanchor:'center',yanchor:'top',len:0.8,thickness:16,title:{text:'dBZ',side:'bottom'},
|
||||||
|
tickmode:'array',tickvals:Array.from({length:15},(_,i)=>i),ticktext:Array.from({length:15},(_,i)=>String(i*5))},
|
||||||
|
hovertemplate:'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>' }];
|
||||||
|
// 扇形覆盖(同样默认显示)
|
||||||
|
if(gWindFromDeg!==null && gWindSpeedMS>0){
|
||||||
|
const half=30, samples=64, start=gWindFromDeg-half, end=gWindFromDeg+half, rangeM=gWindSpeedMS*3*3600;
|
||||||
|
const xsFan=[ST_LON], ysFan=[ST_LAT];
|
||||||
|
for(let i=0;i<=samples;i++){ const θ=start+(end-start)*(i/samples); const p=destPoint(ST_LAT,ST_LON,((θ%360)+360)%360,rangeM); xsFan.push(p.lon); ysFan.push(p.lat); }
|
||||||
|
xsFan.push(ST_LON); ysFan.push(ST_LAT);
|
||||||
|
data.push({ type:'scatter', mode:'lines', x:xsFan, y:ysFan, line:{color:'#FFFFFF',width:2,dash:'dash'}, fill:'toself', fillcolor:'rgba(255,255,255,0.18)', hoverinfo:'skip', showlegend:false });
|
||||||
|
}
|
||||||
|
const el=document.getElementById('squarePlot');
|
||||||
|
const layout={ autosize:true, margin:{l:40,r:8,t:8,b:90},
|
||||||
|
xaxis:{title:'经度', tickformat:'.2f', zeroline:false, constrain:'domain', automargin:true, range:[lonMin, lonMax]},
|
||||||
|
yaxis:{title:{text:'纬度',standoff:12}, tickformat:'.2f', zeroline:false, scaleanchor:'x', scaleratio:1, constrain:'domain', automargin:true, range:[latMin, latMax]},
|
||||||
|
shapes:[{type:'rect',xref:'x',yref:'y',x0:lonMin,x1:lonMax,y0:latMin,y1:latMax,line:{color:'#111',width:1,dash:'dot'},fillcolor:'rgba(0,0,0,0)'}] };
|
||||||
|
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); });
|
||||||
|
}catch(e){ document.getElementById('squarePlot').innerHTML=`<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">正方形热力图渲染失败:${e.message}</div>`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动加载
|
||||||
|
loadLatestTile().catch(err=>{ document.getElementById('radarPlot').innerHTML=`<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">加载失败:${err.message}</div>`; });
|
||||||
|
loadRealtimeLatest().catch(err=>{ document.getElementById('rtInfo').innerHTML=`<div class="text-sm text-red-600">${err.message}</div>`; });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
499
templates/radar_nanning.html
Normal file
499
templates/radar_nanning.html
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ .Title }}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||||||
|
<script src="/static/js/plotly-2.27.0.min.js"></script>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 0; }
|
||||||
|
.content-narrow { width: 86%; max-width: 1200px; margin: 0 auto; }
|
||||||
|
.placeholder { padding: 40px 0; color: #666; text-align: center; }
|
||||||
|
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 20px; }
|
||||||
|
/* 图容器:居中 + 合适尺寸 + 方形比例 */
|
||||||
|
.plot-box { width: clamp(320px, 80vw, 720px); margin: 0 auto; aspect-ratio: 1 / 1; overflow: hidden; }
|
||||||
|
@supports not (aspect-ratio: 1 / 1) {
|
||||||
|
.plot-box { height: 520px; }
|
||||||
|
}
|
||||||
|
#radarTooltip { position: absolute; pointer-events: none; background: #fff; border: 1px solid #e5e7eb; font-size: 12px; padding: 6px 8px; border-radius: 4px; box-shadow: 0 2px 6px rgba(0,0,0,0.15); display: none; z-index: 10; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{ template "header" . }}
|
||||||
|
|
||||||
|
<div class="content-narrow p-4">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="text-lg font-semibold mb-2">最新 7/40/102 瓦片信息</div>
|
||||||
|
<div id="tileInfo" class="text-sm space-y-1">
|
||||||
|
<div>时间:<span id="dt" class="font-mono"></span></div>
|
||||||
|
<div>索引:z=<span id="z"></span> / y=<span id="y"></span> / x=<span id="x"></span></div>
|
||||||
|
<div>尺寸:<span id="size"></span></div>
|
||||||
|
<div>边界:W=<span id="west"></span>,S=<span id="south"></span>,E=<span id="east"></span>,N=<span id="north"></span></div>
|
||||||
|
<div>分辨率(度/像素):<span id="res"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="text-lg font-semibold mb-2">最新雷达站气象(南宁雷达站)</div>
|
||||||
|
<div id="rtInfo" class="text-sm grid grid-cols-2 gap-y-1 gap-x-6">
|
||||||
|
<div>站点:<span id="rt_alias"></span></div>
|
||||||
|
<div>位置:<span id="rt_lat"></span>,<span id="rt_lon"></span></div>
|
||||||
|
<div>时间:<span id="rt_dt" class="font-mono"></span></div>
|
||||||
|
<div>温度:<span id="rt_t"></span></div>
|
||||||
|
<div>湿度:<span id="rt_h"></span></div>
|
||||||
|
<div>云量:<span id="rt_c"></span></div>
|
||||||
|
<div>能见度:<span id="rt_vis"></span> km</div>
|
||||||
|
<div>下行短波:<span id="rt_dswrf"></span> </div>
|
||||||
|
<div>风速:<span id="rt_ws"></span> m/s</div>
|
||||||
|
<div>风向:<span id="rt_wd"></span> °</div>
|
||||||
|
<div>气压:<span id="rt_p"></span> Pa</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="text-lg font-semibold mb-2">未来 3H 降雨评估(按来风±30°扇形)</div>
|
||||||
|
<div id="sectorInfo" class="text-sm">
|
||||||
|
<div id="sectorStatus">计算中…</div>
|
||||||
|
<div id="sectorDetail" class="mt-1 hidden">
|
||||||
|
最近距离:<span id="sectorDist"></span> km;
|
||||||
|
预计到达时间:<span id="sectorETA"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="width: 100%;">
|
||||||
|
<div class="text-lg font-semibold mb-2">雷达组合反射率</div>
|
||||||
|
<div id="radarPlot" class="plot-box"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card mt-4" style="width: 100%;">
|
||||||
|
<div class="text-lg font-semibold mb-2">正方形裁减区域</div>
|
||||||
|
<div id="squarePlot" class="plot-box"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let gTileValues = null, gXs = null, gYs = null;
|
||||||
|
let gWindFromDeg = null, gWindSpeedMS = null; // 来风方向(度,0=北,顺时针),风速 m/s
|
||||||
|
|
||||||
|
async function loadLatestTile() {
|
||||||
|
const res = await fetch('/api/radar/latest?z=7&y=40&x=102');
|
||||||
|
if (!res.ok) { throw new Error('加载最新瓦片失败'); }
|
||||||
|
const t = await res.json();
|
||||||
|
const fmt5 = (n) => Number(n).toFixed(5);
|
||||||
|
|
||||||
|
// 填充元信息
|
||||||
|
document.getElementById('dt').textContent = t.dt;
|
||||||
|
document.getElementById('z').textContent = t.z;
|
||||||
|
document.getElementById('y').textContent = t.y;
|
||||||
|
document.getElementById('x').textContent = t.x;
|
||||||
|
document.getElementById('size').textContent = `${t.width} × ${t.height}`;
|
||||||
|
document.getElementById('west').textContent = fmt5(t.west);
|
||||||
|
document.getElementById('south').textContent = fmt5(t.south);
|
||||||
|
document.getElementById('east').textContent = fmt5(t.east);
|
||||||
|
document.getElementById('north').textContent = fmt5(t.north);
|
||||||
|
document.getElementById('res').textContent = fmt5(t.res_deg);
|
||||||
|
|
||||||
|
// Plotly 热力图(离散 5 dBZ 色带)
|
||||||
|
const colors = [
|
||||||
|
"#0000F6","#01A0F6","#00ECEC","#01FF00","#00C800",
|
||||||
|
"#019000","#FFFF00","#E7C000","#FF9000","#FF0000",
|
||||||
|
"#D60000","#C00000","#FF00F0","#780084","#AD90F0"
|
||||||
|
];
|
||||||
|
const w = t.width, h = t.height;
|
||||||
|
const resDeg = t.res_deg;
|
||||||
|
const west = t.west, south = t.south, north = t.north;
|
||||||
|
|
||||||
|
// 经纬度轴(像元中心)。x 自西向东递增;y 自南向北递增(保证上=北,下=南)。
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 缓存用于后续扇形计算
|
||||||
|
gTileValues = t.values;
|
||||||
|
gXs = xs; gYs = ys;
|
||||||
|
|
||||||
|
// 将 dBZ 离散到 0..14 档,并保留原值用于 hover
|
||||||
|
const zBins = new Array(h);
|
||||||
|
const custom = new Array(h);
|
||||||
|
for (let r = 0; r < h; r++) {
|
||||||
|
const rowBins = new Array(w);
|
||||||
|
const rowCustom = new Array(w);
|
||||||
|
for (let c = 0; c < w; c++) {
|
||||||
|
const val = t.values[r][c];
|
||||||
|
if (val === null || val === undefined) { rowBins[c] = null; rowCustom[c] = [r,c,null]; continue; }
|
||||||
|
let dbz = Number(val); if (dbz < 0) dbz = 0; if (dbz > 75) dbz = 75;
|
||||||
|
let bin = Math.floor(dbz / 5); if (bin >= 15) bin = 14;
|
||||||
|
rowBins[c] = bin; rowCustom[c] = [r, c, dbz];
|
||||||
|
}
|
||||||
|
zBins[r] = rowBins; custom[r] = rowCustom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 颜色刻度(0..14 归一化到 0..1)
|
||||||
|
const scale = [];
|
||||||
|
for (let i = 0; i < colors.length; i++) {
|
||||||
|
const tpos = colors.length === 1 ? 0 : i / (colors.length - 1);
|
||||||
|
scale.push([tpos, colors[i]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = [{
|
||||||
|
type: 'heatmap',
|
||||||
|
x: xs,
|
||||||
|
y: ys,
|
||||||
|
z: zBins,
|
||||||
|
customdata: custom,
|
||||||
|
colorscale: scale,
|
||||||
|
zmin: 0,
|
||||||
|
zmax: 14,
|
||||||
|
colorbar: {
|
||||||
|
orientation: 'h',
|
||||||
|
x: 0.5,
|
||||||
|
y: -0.12,
|
||||||
|
xanchor: 'center',
|
||||||
|
yanchor: 'top',
|
||||||
|
len: 0.8,
|
||||||
|
thickness: 16,
|
||||||
|
title: { text: 'dBZ', side: 'bottom' },
|
||||||
|
tickmode: 'array',
|
||||||
|
tickvals: Array.from({length: 15}, (_,i)=>i),
|
||||||
|
ticktext: Array.from({length: 15}, (_,i)=>String(i*5))
|
||||||
|
},
|
||||||
|
hovertemplate: 'row=%{customdata[0]}, col=%{customdata[1]}<br>'+
|
||||||
|
'lon=%{x:.3f}, lat=%{y:.3f}<br>'+
|
||||||
|
'dBZ=%{customdata[2]:.1f}<extra></extra>'
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 叠加“来风±30°扇形(3小时可达半径)”覆盖层(默认显示、无图例项)到主图
|
||||||
|
if (gWindFromDeg !== null && gWindSpeedMS > 0) {
|
||||||
|
const stLat = 23.097234, stLon = 108.715433;
|
||||||
|
const half = 30; // 半开角
|
||||||
|
const samples = 64; // 弧线采样点
|
||||||
|
const start = gWindFromDeg - half;
|
||||||
|
const end = gWindFromDeg + half;
|
||||||
|
const rangeM = gWindSpeedMS * 3 * 3600;
|
||||||
|
const xsFan = [];
|
||||||
|
const ysFan = [];
|
||||||
|
xsFan.push(stLon); ysFan.push(stLat);
|
||||||
|
for (let i = 0; i <= samples; i++) {
|
||||||
|
const θ = start + (end - start) * (i / samples);
|
||||||
|
const p = destPoint(stLat, stLon, ((θ % 360) + 360) % 360, rangeM);
|
||||||
|
xsFan.push(p.lon); ysFan.push(p.lat);
|
||||||
|
}
|
||||||
|
xsFan.push(stLon); ysFan.push(stLat);
|
||||||
|
data.push({
|
||||||
|
type: 'scatter', mode: 'lines', x: xsFan, y: ysFan,
|
||||||
|
line: { color: '#FFFFFF', width: 2, dash: 'dash' },
|
||||||
|
fill: 'toself', fillcolor: 'rgba(255,255,255,0.18)',
|
||||||
|
hoverinfo: 'skip', showlegend: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = {
|
||||||
|
autosize: true,
|
||||||
|
margin: {l:40, r:8, t:8, b:90},
|
||||||
|
xaxis: {title: '经度', tickformat: '.2f', zeroline: false, constrain: 'domain', automargin: true},
|
||||||
|
// y 轴默认从小到大(下→上),配合 ys 自南到北递增,实现“上=北,下=南”
|
||||||
|
yaxis: {title: {text: '纬度', standoff: 12}, tickformat: '.2f', zeroline: false, scaleanchor: 'x', scaleratio: 1, constrain: 'domain', automargin: true},
|
||||||
|
};
|
||||||
|
|
||||||
|
const plotEl = document.getElementById('radarPlot');
|
||||||
|
Plotly.newPlot(plotEl, data, layout, {responsive: true, displayModeBar: false}).then(() => {
|
||||||
|
// 强制按照容器宽度设置高度,保持方形
|
||||||
|
const size = plotEl.clientWidth;
|
||||||
|
Plotly.relayout(plotEl, {height: size});
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
const size = plotEl.clientWidth;
|
||||||
|
Plotly.relayout(plotEl, {height: size});
|
||||||
|
Plotly.Plots.resize(plotEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 同步加载与瓦片时间对应的10分钟气象(向上取整到10分)
|
||||||
|
try {
|
||||||
|
const dt = new Date(t.dt.replace(/-/g,'/')); // iOS兼容
|
||||||
|
const ceil10 = new Date(dt);
|
||||||
|
const m = dt.getMinutes();
|
||||||
|
const up = (Math.floor(m/10)*10 + 10) % 60;
|
||||||
|
ceil10.setMinutes(up, 0, 0);
|
||||||
|
if (up === 0) { ceil10.setHours(dt.getHours()+1); }
|
||||||
|
const pad = (n)=> n.toString().padStart(2,'0');
|
||||||
|
const dtStr = `${ceil10.getFullYear()}-${pad(ceil10.getMonth()+1)}-${pad(ceil10.getDate())} ${pad(ceil10.getHours())}:${pad(ceil10.getMinutes())}:00`;
|
||||||
|
await loadRealtimeAt(dtStr);
|
||||||
|
// 扇形计算 + 正方形裁剪热力图
|
||||||
|
maybeCalcSector();
|
||||||
|
maybePlotSquare();
|
||||||
|
} catch (e) {
|
||||||
|
// 回退到最新
|
||||||
|
await loadRealtimeLatest();
|
||||||
|
maybeCalcSector();
|
||||||
|
maybePlotSquare();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRealtimeLatest() {
|
||||||
|
const alias = encodeURIComponent('南宁雷达站');
|
||||||
|
const res = await fetch(`/api/radar/weather_latest?alias=${alias}`);
|
||||||
|
if (!res.ok) throw new Error('加载实时气象失败');
|
||||||
|
const r = await res.json();
|
||||||
|
const f2 = (n) => Number(n).toFixed(2);
|
||||||
|
const f4 = (n) => Number(n).toFixed(4);
|
||||||
|
document.getElementById('rt_alias').textContent = r.alias;
|
||||||
|
document.getElementById('rt_lat').textContent = f4(r.lat);
|
||||||
|
document.getElementById('rt_lon').textContent = f4(r.lon);
|
||||||
|
document.getElementById('rt_dt').textContent = r.dt;
|
||||||
|
document.getElementById('rt_t').textContent = f2(r.temperature);
|
||||||
|
document.getElementById('rt_h').textContent = f2(r.humidity);
|
||||||
|
document.getElementById('rt_c').textContent = f2(r.cloudrate);
|
||||||
|
document.getElementById('rt_vis').textContent = f2(r.visibility);
|
||||||
|
document.getElementById('rt_dswrf').textContent = f2(r.dswrf);
|
||||||
|
document.getElementById('rt_ws').textContent = f2(r.wind_speed);
|
||||||
|
document.getElementById('rt_wd').textContent = f2(r.wind_direction);
|
||||||
|
document.getElementById('rt_p').textContent = f2(r.pressure);
|
||||||
|
|
||||||
|
gWindFromDeg = Number(r.wind_direction); // 来风方向(度)
|
||||||
|
gWindSpeedMS = Number(r.wind_speed); // m/s(后端已转好)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRealtimeAt(dtStr) {
|
||||||
|
const alias = encodeURIComponent('南宁雷达站');
|
||||||
|
const res = await fetch(`/api/radar/weather_at?alias=${alias}&dt=${encodeURIComponent(dtStr)}`);
|
||||||
|
if (!res.ok) throw new Error('加载指定时间气象失败');
|
||||||
|
const r = await res.json();
|
||||||
|
const f2 = (n) => Number(n).toFixed(2);
|
||||||
|
const f4 = (n) => Number(n).toFixed(4);
|
||||||
|
document.getElementById('rt_alias').textContent = r.alias;
|
||||||
|
document.getElementById('rt_lat').textContent = f4(r.lat);
|
||||||
|
document.getElementById('rt_lon').textContent = f4(r.lon);
|
||||||
|
document.getElementById('rt_dt').textContent = r.dt;
|
||||||
|
document.getElementById('rt_t').textContent = f2(r.temperature);
|
||||||
|
document.getElementById('rt_h').textContent = f2(r.humidity);
|
||||||
|
document.getElementById('rt_c').textContent = f2(r.cloudrate);
|
||||||
|
document.getElementById('rt_vis').textContent = f2(r.visibility);
|
||||||
|
document.getElementById('rt_dswrf').textContent = f2(r.dswrf);
|
||||||
|
document.getElementById('rt_ws').textContent = f2(r.wind_speed);
|
||||||
|
document.getElementById('rt_wd').textContent = f2(r.wind_direction);
|
||||||
|
document.getElementById('rt_p').textContent = f2(r.pressure);
|
||||||
|
|
||||||
|
gWindFromDeg = Number(r.wind_direction);
|
||||||
|
gWindSpeedMS = Number(r.wind_speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRad(d){ return d * Math.PI / 180; }
|
||||||
|
function toDeg(r){ return r * 180 / Math.PI; }
|
||||||
|
function haversine(lat1, lon1, lat2, lon2){
|
||||||
|
const R = 6371000; // m
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2)**2;
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
function bearingDeg(lat1, lon1, lat2, lon2){
|
||||||
|
const φ1 = toRad(lat1), φ2 = toRad(lat2), Δλ = toRad(lon2 - lon1);
|
||||||
|
const y = Math.sin(Δλ) * Math.cos(φ2);
|
||||||
|
const x = Math.cos(φ1)*Math.sin(φ2) - Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ);
|
||||||
|
let brng = toDeg(Math.atan2(y, x));
|
||||||
|
return (brng + 360) % 360; // 0..360, 0=北,顺时针
|
||||||
|
}
|
||||||
|
function angDiff(a, b){
|
||||||
|
let d = ((a - b + 540) % 360) - 180; // -180..180
|
||||||
|
return Math.abs(d);
|
||||||
|
}
|
||||||
|
// 前向大地解算:从(lat,lon)出发,沿方位角brgDeg,距离distM,求目的地经纬度
|
||||||
|
function destPoint(lat, lon, brgDeg, distM){
|
||||||
|
const R = 6371000; // m
|
||||||
|
const δ = distM / R;
|
||||||
|
const θ = toRad(brgDeg);
|
||||||
|
const φ1 = toRad(lat);
|
||||||
|
const λ1 = toRad(lon);
|
||||||
|
const sinφ1 = Math.sin(φ1), cosφ1 = Math.cos(φ1);
|
||||||
|
const sinδ = Math.sin(δ), cosδ = Math.cos(δ);
|
||||||
|
const sinφ2 = sinφ1 * cosδ + cosφ1 * sinδ * Math.cos(θ);
|
||||||
|
const φ2 = Math.asin(sinφ2);
|
||||||
|
const y = Math.sin(θ) * sinδ * cosφ1;
|
||||||
|
const x = cosδ - sinφ1 * sinφ2;
|
||||||
|
const λ2 = λ1 + Math.atan2(y, x);
|
||||||
|
return { lat: toDeg(φ2), lon: ((toDeg(λ2) + 540) % 360) - 180 }; // 归一化到[-180,180)
|
||||||
|
}
|
||||||
|
function maybeCalcSector(){
|
||||||
|
try{
|
||||||
|
if(!gTileValues || !gXs || !gYs || gWindFromDeg===null || gWindSpeedMS===null){
|
||||||
|
return; // 等待数据就绪
|
||||||
|
}
|
||||||
|
// 站点(南宁雷达站经纬度)
|
||||||
|
const stLat = 23.097234, stLon = 108.715433;
|
||||||
|
const halfAngle = 30; // ±30°
|
||||||
|
const rangeM = gWindSpeedMS * 3 * 3600; // 三小时覆盖半径
|
||||||
|
|
||||||
|
let best = null; // {dist, lat, lon, dbz}
|
||||||
|
const h = gTileValues.length;
|
||||||
|
const w = gTileValues[0].length;
|
||||||
|
for(let r=0; r<h; r++){
|
||||||
|
const lat = gYs[r];
|
||||||
|
for(let c=0; c<w; c++){
|
||||||
|
const val = gTileValues[r][c];
|
||||||
|
if(val==null) continue;
|
||||||
|
const dbz = Number(val);
|
||||||
|
if(!(dbz >= 40)) continue; // 门限
|
||||||
|
const lon = gXs[c];
|
||||||
|
const dist = haversine(stLat, stLon, lat, lon);
|
||||||
|
if(dist > rangeM) continue;
|
||||||
|
const brg = bearingDeg(stLat, stLon, lat, lon);
|
||||||
|
// 以“来风方向”为扇形中心
|
||||||
|
if(angDiff(brg, gWindFromDeg) > halfAngle) continue;
|
||||||
|
if(!best || dist < best.dist){ best = {dist, lat, lon, dbz}; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const statusEl = document.getElementById('sectorStatus');
|
||||||
|
const detailEl = document.getElementById('sectorDetail');
|
||||||
|
if(!best){
|
||||||
|
statusEl.textContent = '无(≥40 dBZ)';
|
||||||
|
detailEl.classList.add('hidden');
|
||||||
|
}else{
|
||||||
|
const etaSec = best.dist / gWindSpeedMS;
|
||||||
|
const eta = new Date(Date.now() + etaSec*1000);
|
||||||
|
const pad = (n)=> String(n).padStart(2,'0');
|
||||||
|
const etaStr = `${eta.getFullYear()}-${pad(eta.getMonth()+1)}-${pad(eta.getDate())} ${pad(eta.getHours())}:${pad(eta.getMinutes())}`;
|
||||||
|
document.getElementById('sectorDist').textContent = (best.dist/1000).toFixed(1);
|
||||||
|
document.getElementById('sectorETA').textContent = etaStr;
|
||||||
|
statusEl.textContent = '三小时内可能有降雨(≥40 dBZ )';
|
||||||
|
detailEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
const statusEl = document.getElementById('sectorStatus');
|
||||||
|
statusEl.textContent = '风险评估计算失败:' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybePlotSquare(){
|
||||||
|
try{
|
||||||
|
if(!gTileValues || !gXs || !gYs || gWindSpeedMS===null){
|
||||||
|
return; // 等待数据
|
||||||
|
}
|
||||||
|
// 常量与站点
|
||||||
|
const R = 6371000; // 地球半径(m)
|
||||||
|
const stLat = 23.097234, stLon = 108.715433;
|
||||||
|
const rangeM = gWindSpeedMS * 3 * 3600; // 3小时
|
||||||
|
// 米->度(近似):
|
||||||
|
const dLat = (rangeM / R) * (180/Math.PI);
|
||||||
|
const dLon = (rangeM / (R * Math.cos(toRad(stLat)))) * (180/Math.PI);
|
||||||
|
const latMin = stLat - dLat, latMax = stLat + dLat;
|
||||||
|
const lonMin = stLon - dLon, lonMax = stLon + dLon;
|
||||||
|
|
||||||
|
// 定位索引范围(y从南到北递增, x从西到东递增)
|
||||||
|
const h = gYs.length, w = gXs.length;
|
||||||
|
let rStart = 0; while(rStart < h && gYs[rStart] < latMin) rStart++;
|
||||||
|
let rEnd = h-1; while(rEnd >= 0 && gYs[rEnd] > latMax) rEnd--;
|
||||||
|
let cStart = 0; while(cStart < w && gXs[cStart] < lonMin) cStart++;
|
||||||
|
let cEnd = w-1; while(cEnd >= 0 && gXs[cEnd] > lonMax) cEnd--;
|
||||||
|
if(rStart >= rEnd || cStart >= cEnd){
|
||||||
|
const el = document.getElementById('squarePlot');
|
||||||
|
el.innerHTML = '<div class="p-3 text-sm text-gray-600">正方形范围超出当前瓦片或无有效像元</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xs = gXs.slice(cStart, cEnd+1);
|
||||||
|
const ys = gYs.slice(rStart, rEnd+1);
|
||||||
|
|
||||||
|
// 离散到色带(与主图一致)
|
||||||
|
const colors = [
|
||||||
|
"#0000F6","#01A0F6","#00ECEC","#01FF00","#00C800",
|
||||||
|
"#019000","#FFFF00","#E7C000","#FF9000","#FF0000",
|
||||||
|
"#D60000","#C00000","#FF00F0","#780084","#AD90F0"
|
||||||
|
];
|
||||||
|
const zBins = [];
|
||||||
|
const custom = [];
|
||||||
|
for(let r=rStart; r<=rEnd; r++){
|
||||||
|
const rowBins = [];
|
||||||
|
const rowCustom = [];
|
||||||
|
for(let c=cStart; c<=cEnd; c++){
|
||||||
|
const val = gTileValues[r][c];
|
||||||
|
if(val==null){ rowBins.push(null); rowCustom.push([r,c,null]); continue; }
|
||||||
|
let dbz = Number(val); if (dbz < 0) dbz = 0; if (dbz > 75) dbz = 75;
|
||||||
|
let bin = Math.floor(dbz / 5); if (bin >= 15) bin = 14;
|
||||||
|
rowBins.push(bin); rowCustom.push([r,c,dbz]);
|
||||||
|
}
|
||||||
|
zBins.push(rowBins); custom.push(rowCustom);
|
||||||
|
}
|
||||||
|
const scale = [];
|
||||||
|
for (let i = 0; i < colors.length; i++) {
|
||||||
|
const tpos = colors.length === 1 ? 0 : i / (colors.length - 1);
|
||||||
|
scale.push([tpos, colors[i]]);
|
||||||
|
}
|
||||||
|
const data = [{
|
||||||
|
type: 'heatmap',
|
||||||
|
x: xs,
|
||||||
|
y: ys,
|
||||||
|
z: zBins,
|
||||||
|
customdata: custom,
|
||||||
|
colorscale: scale,
|
||||||
|
zmin: 0,
|
||||||
|
zmax: 14,
|
||||||
|
colorbar: {
|
||||||
|
orientation: 'h', x: 0.5, y: -0.12, xanchor: 'center', yanchor: 'top',
|
||||||
|
len: 0.8, thickness: 16, title: { text: 'dBZ', side: 'bottom' },
|
||||||
|
tickmode: 'array', tickvals: Array.from({length: 15}, (_,i)=>i),
|
||||||
|
ticktext: Array.from({length: 15}, (_,i)=>String(i*5))
|
||||||
|
},
|
||||||
|
hovertemplate: 'lon=%{x:.3f}, lat=%{y:.3f}<br>dBZ=%{customdata[2]:.1f}<extra></extra>'
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 叠加“来风±30°扇形(3小时可达半径)”覆盖层(默认显示、无图例项)
|
||||||
|
if (gWindFromDeg !== null && gWindSpeedMS > 0) {
|
||||||
|
const half = 30; // 半开角
|
||||||
|
const samples = 64; // 弧线采样点
|
||||||
|
const start = gWindFromDeg - half;
|
||||||
|
const end = gWindFromDeg + half;
|
||||||
|
const xsFan = [];
|
||||||
|
const ysFan = [];
|
||||||
|
// 从中心起点,沿弧线,再回到中心,闭合多边形
|
||||||
|
xsFan.push(stLon); ysFan.push(stLat);
|
||||||
|
for (let i = 0; i <= samples; i++) {
|
||||||
|
const θ = start + (end - start) * (i / samples);
|
||||||
|
const p = destPoint(stLat, stLon, ((θ % 360) + 360) % 360, rangeM);
|
||||||
|
xsFan.push(p.lon); ysFan.push(p.lat);
|
||||||
|
}
|
||||||
|
xsFan.push(stLon); ysFan.push(stLat);
|
||||||
|
data.push({
|
||||||
|
type: 'scatter', mode: 'lines', x: xsFan, y: ysFan,
|
||||||
|
line: { color: '#FFFFFF', width: 2, dash: 'dash' },
|
||||||
|
fill: 'toself', fillcolor: 'rgba(255,255,255,0.18)',
|
||||||
|
hoverinfo: 'skip', showlegend: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const layout = {
|
||||||
|
autosize: true,
|
||||||
|
margin: {l:40, r:8, t:8, b:90},
|
||||||
|
xaxis: {title: '经度', tickformat: '.2f', zeroline: false, constrain: 'domain', automargin: true},
|
||||||
|
yaxis: {title: {text: '纬度', standoff: 12}, tickformat: '.2f', zeroline: false, scaleanchor: 'x', scaleratio: 1, constrain: 'domain', automargin: true},
|
||||||
|
shapes: [
|
||||||
|
// 画出正方形边界(lonMin..lonMax, latMin..latMax)
|
||||||
|
{type:'rect', xref:'x', yref:'y', x0: lonMin, x1: lonMax, y0: latMin, y1: latMax, line: {color:'#111', width:1, dash:'dot'}, fillcolor:'rgba(0,0,0,0)'}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const el = document.getElementById('squarePlot');
|
||||||
|
Plotly.newPlot(el, data, layout, {responsive: true, displayModeBar: false}).then(()=>{
|
||||||
|
const size = el.clientWidth;
|
||||||
|
Plotly.relayout(el, {height: size});
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
const size = el.clientWidth;
|
||||||
|
Plotly.relayout(el, {height: size});
|
||||||
|
Plotly.Plots.resize(el);
|
||||||
|
});
|
||||||
|
}catch(e){
|
||||||
|
const el = document.getElementById('squarePlot');
|
||||||
|
el.innerHTML = `<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">正方形热力图渲染失败:${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLatestTile().catch(err => {
|
||||||
|
const plot = document.getElementById('radarPlot');
|
||||||
|
plot.innerHTML = `<div class="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">加载失败:${err.message}(请确认 /static/js/plotly-2.27.0.min.js 已存在)</div>`;
|
||||||
|
});
|
||||||
|
// 兜底加载最新
|
||||||
|
loadRealtimeLatest().catch(err => {
|
||||||
|
const info = document.getElementById('rtInfo');
|
||||||
|
info.innerHTML = `<div class="text-sm text-red-600">${err.message}</div>`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user