feat: 扇形雷达

This commit is contained in:
yarnom 2025-09-23 15:15:46 +08:00
parent da67660fe7
commit 11e5c73275
13 changed files with 1896 additions and 4 deletions

View File

@ -10,6 +10,7 @@ import (
"weatherstation/internal/config" "weatherstation/internal/config"
"weatherstation/internal/database" "weatherstation/internal/database"
"weatherstation/internal/forecast" "weatherstation/internal/forecast"
"weatherstation/internal/radar"
"weatherstation/internal/selftest" "weatherstation/internal/selftest"
"weatherstation/internal/server" "weatherstation/internal/server"
"weatherstation/internal/tools" "weatherstation/internal/tools"
@ -44,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()
// 设置日志 // 设置日志
@ -110,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 == "" {
@ -222,9 +239,26 @@ func main() {
}() }()
} }
startRadarSchedulerBackground := func(wg *sync.WaitGroup) {
if wg != nil {
wg.Add(1)
}
go func() {
defer func() {
if wg != nil {
wg.Done()
}
}()
log.Println("启动雷达下载任务每10分钟无延迟固定瓦片 7/40/102...")
ctx := context.Background()
_ = radar.Start(ctx, radar.Options{StoreToDB: true, Z: 7, Y: 40, X: 102})
}()
}
if *webOnly { if *webOnly {
// 只启动Web服务器 + 导出器 // 只启动Web服务器 + 导出器
startExporterBackground(nil) startExporterBackground(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)
@ -232,6 +266,7 @@ func main() {
} else if *udpOnly { } else if *udpOnly {
// 只启动UDP服务器 + 导出器 // 只启动UDP服务器 + 导出器
startExporterBackground(nil) startExporterBackground(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)
@ -262,6 +297,7 @@ func main() {
}() }()
startExporterBackground(&wg) startExporterBackground(&wg)
startRadarSchedulerBackground(&wg)
wg.Wait() wg.Wait()
} }
} }

View File

@ -537,4 +537,30 @@ CREATE INDEX idx_fcast_station_time ON public.forecast_hourly USING btree (stati
COMMENT ON TABLE public.forecast_hourly IS '小时级预报表按issued_at版本化要素使用缩放整数存储'; 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分钟存档';

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

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

View File

@ -29,6 +29,8 @@ func StartGinServer() error {
// 路由设置 // 路由设置
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")
@ -37,6 +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", latestRadarTileHandler)
api.GET("/radar/weather_latest", latestRadarWeatherHandler)
api.GET("/radar/weather_at", radarWeatherAtHandler)
} }
// 获取配置的Web端口 // 获取配置的Web端口
@ -61,6 +66,28 @@ func indexHandler(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", data) c.HTML(http.StatusOK, "index.html", data)
} }
// 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) {
status := types.SystemStatus{ status := types.SystemStatus{

View 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

File diff suppressed because one or more lines are too long

15
templates/_header.html Normal file
View 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 }}

View File

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

View 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>

View 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>