Compare commits

...

6 Commits

Author SHA1 Message Date
11e5c73275 feat: 扇形雷达 2025-09-23 15:15:46 +08:00
da67660fe7 Revert "feat: 新增雷达图"
This reverts commit 448b13c2f6eb9b505e516858c27b571eafca1879.
2025-09-23 09:33:12 +08:00
a03c60469f Revert "feat: 新增雷达图"
This reverts commit 4fa9822405104095a9923e9762a2f65a1973d903.
2025-09-23 09:33:07 +08:00
2c7f9a0f47 Revert "feat: 新增雷达图"
This reverts commit 6bc0610c2deb96b8527047c657acf0f1f594d6a1.
2025-09-23 09:33:06 +08:00
cfb0bca723 Revert "feat: 新增雷达图"
This reverts commit df7358530f428751cdbce3f4220f1925e7b616c2.
2025-09-23 09:33:00 +08:00
0da2c838c2 Revert "feat: 新增雷达图"
- 使用了错误的方法,终止此特性分支

This reverts commit 317e12900a663ff4c1387b3430c660303a4e3462.
2025-09-23 09:32:30 +08:00
25 changed files with 1885 additions and 1815 deletions

View File

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

View File

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

View File

@ -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分钟存档';

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
} }
// 备注:雷达站采用前端 Tabhash切换无需单独路由 // 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 }

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

@ -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">
<div>
<strong>在线设备: </strong> <span id="onlineDevices">{{.OnlineDevices}}</span> 个 | <strong>在线设备: </strong> <span id="onlineDevices">{{.OnlineDevices}}</span> 个 |
<strong>总设备: </strong> <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>
<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">
@ -596,365 +558,9 @@
</div> </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>
<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,'&lt;')+'</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>

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>