phase 0-2 complete
This commit is contained in:
198
TODO.md
198
TODO.md
@@ -1,169 +1,103 @@
|
|||||||
# Project Heloha
|
# Project Heloha
|
||||||
|
|
||||||
A detailed, actionable task list for building a unified, multi-radar severe weather analysis and alerting platform for Oklahoma.
|
A unified, multi-radar severe weather analysis and alerting platform for Oklahoma.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 0: Foundation & Setup (The First Week)
|
## Phase 0: Foundation & Setup ✅
|
||||||
|
|
||||||
*Goal: Establish a clean, professional Go project environment.*
|
- [x] Git repo, `.gitignore`, `README.md`
|
||||||
|
- [x] Go module (`github.com/blakeridgway/heloha`), standard project layout
|
||||||
- [x] **Initialize Git Repository:**
|
- [x] `go-chi/chi` HTTP router with middleware
|
||||||
- [x] `git init`
|
- [x] `/healthz` endpoint
|
||||||
- [x] Create a `.gitignore` file for Go and common OS files.
|
- [x] `Makefile` with `build`, `run`, `test`, `tidy`
|
||||||
- [x] Create a `README.md` with the project's mission statement.
|
|
||||||
- [x] **Establish Go Project Structure:**
|
|
||||||
- [x] `go mod init github.com/blakeridgway/heloha`
|
|
||||||
- [x] Create a standard Go project layout:
|
|
||||||
- `/cmd/heloha-server`: Main application entry point.
|
|
||||||
- `/internal/radar`: Code for fetching, parsing, and processing radar data.
|
|
||||||
- `/internal/server`: HTTP handlers and server logic.
|
|
||||||
- `/internal/config`: Configuration management.
|
|
||||||
- `/web/templates`: HTML templates for the frontend.
|
|
||||||
- `/web/static`: CSS and JavaScript assets.
|
|
||||||
- [x] **Create a Basic Web Server:**
|
|
||||||
- [x] In `/cmd/heloha-server/main.go`, set up an `http.Server`.
|
|
||||||
- [x] Choose and implement a router (e.g., `go-chi/chi` is a good choice for middleware and flexibility).
|
|
||||||
- [x] Create a simple `/healthz` endpoint that returns `200 OK`.
|
|
||||||
- [x] **Set Up Build Automation:**
|
|
||||||
- [x] Create a `Makefile` with targets for:
|
|
||||||
- `build`: `go build -o bin/heloha-server ./cmd/heloha-server`
|
|
||||||
- `run`: `go run ./cmd/heloha-server`
|
|
||||||
- `test`: `go test ./...`
|
|
||||||
- `tidy`: `go mod tidy`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 1: Single Radar Ingestion & Display (The MVP)
|
## Phase 1: Single Radar Ingestion & Display ✅
|
||||||
|
|
||||||
*Goal: Get raw data from one radar onto a map on a webpage. This proves the core data pipeline is viable.*
|
*Approach changed from AWS S3 → NWS public FTP (`tgftp.nws.noaa.gov`). No credentials required.*
|
||||||
|
|
||||||
- [ ] **Data Acquisition (`/internal/radar/fetch.go`):**
|
- [x] Fetch latest Level 3 N0Q (Digital Base Reflectivity 0.5°) via HTTP from NWS FTP
|
||||||
- [ ] Add the AWS SDK for Go V2 (`aws-sdk-go-v2`) as a dependency.
|
- [x] Parse NEXRAD Level 3 ICD-2620001 binary (WMO header strip, bzip2 Symbology Block, Packet 16 radials)
|
||||||
- [ ] **Use NEXRAD Level 3 data** from the `unidata-nexrad-level3` S3 bucket. Level 3 products are pre-processed by the RPG into a simple binary format — no polar-to-Cartesian projection needed, and no per-record bzip2 complexity.
|
- [x] Web Mercator tile renderer with bilinear interpolation between radials and range bins
|
||||||
- [ ] Implement logic to find the *latest* file for a given radar (`KTLX`) and product code (`N0B` = Base Reflectivity 0.5° tilt) using the S3 key naming convention.
|
- [x] In-memory tile cache (`sync.RWMutex` map), invalidated on each ingest cycle
|
||||||
- [ ] Write a function to download a specific Level 3 file from S3 into memory.
|
- [x] Leaflet.js frontend centered on Oklahoma, dark CartoDB basemap
|
||||||
- [ ] *Note: Level 2 data (raw I/Q + full velocity fields) will be introduced in Phase 3 when TVS detection requires it. Level 3 is sufficient for reflectivity display and mosaic.*
|
- [x] HUD with site label, scan time, UTC + CDT live clock
|
||||||
- [ ] **Data Parsing (`/internal/radar/parse.go`):**
|
- [x] dBZ color legend
|
||||||
- [ ] Implement a Level 3 product parser targeting the Graphic Product Message format (ICD 2620001). The header is fixed-width; the symbology block contains pre-gridded radial data.
|
- [x] 20 dBZ minimum threshold + 4-pass speckle filter to suppress AP/biological noise
|
||||||
- [ ] Define Go `structs` to hold the parsed data: a `RadarProduct` struct with base reflectivity (a 2D array of floats), timestamp, elevation angle, and radar site coordinates.
|
|
||||||
- [ ] **Visualization Backend (`/internal/server/handlers.go`):**
|
|
||||||
- [ ] Create a new HTTP handler for map tiles: `/api/v1/tile/ktlx/{z}/{x}/{y}.png`.
|
|
||||||
- [ ] Inside the handler, implement logic to:
|
|
||||||
- Load the latest parsed `KTLX` data.
|
|
||||||
- Use the Z/X/Y tile coordinates to determine the required geographic bounds.
|
|
||||||
- Map the pre-gridded radar data onto the requested tile canvas (no polar projection needed at this stage).
|
|
||||||
- Use a color lookup table (LUT) to convert dBZ values to colors (e.g., green for light rain, red for heavy).
|
|
||||||
- Render the final tile as a PNG image using Go's standard `image` package.
|
|
||||||
- [ ] **Tile Caching (`/internal/server/tilecache.go`):**
|
|
||||||
- [ ] Implement an in-memory tile cache as a `map[string][]byte` (key: `"z/x/y"`) protected by a `sync.RWMutex`.
|
|
||||||
- [ ] Wrap the tile handler: check the cache first; on a miss, render the PNG, store it in the cache, and return it.
|
|
||||||
- [ ] Tie cache invalidation to the data update cycle — when a new `RadarProduct` is ingested, clear the cache for that radar. This ensures tiles are never stale beyond one update interval.
|
|
||||||
- [ ] **Frontend Display (`/web/templates/index.html`):**
|
|
||||||
- [ ] Set up a basic HTML page that includes Leaflet.js from a CDN.
|
|
||||||
- [ ] Initialize a Leaflet map centered on Oklahoma.
|
|
||||||
- [ ] Add a `L.tileLayer` pointing to your new `/api/v1/tile/ktlx/{z}/{x}/{y}.png` endpoint.
|
|
||||||
- [ ] Add the HTMX script tag.
|
|
||||||
- [ ] Use `hx-get` and `hx-trigger="every 60s"` on a container element to periodically refresh the map layer or associated metadata.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2: Multi-Radar Fusion (The Core Challenge)
|
## Phase 2: Multi-Radar Fusion ✅
|
||||||
|
|
||||||
*Goal: Transition from a single, siloed view to a single, authoritative statewide weather picture.*
|
*Nearest-radar compositing done per-pixel at tile render time — no pre-built mosaic grid needed.*
|
||||||
|
|
||||||
- [ ] **Concurrent Ingestion (`/internal/radar/manager.go`):**
|
- [x] 15 NEXRAD sites (OK, TX, KS, AR, MO, LA) ingested concurrently every 2 minutes
|
||||||
- [ ] Design a concurrent manager that spawns one "fetcher" goroutine per radar (KTLX, KINX, KFDR, KVNX, etc.).
|
- [x] Per-site, per-product ring buffer (12 frames of history)
|
||||||
- [ ] Each fetcher goroutine periodically checks for new data for its assigned radar.
|
- [x] Composite tile endpoint: nearest radar per pixel within 230 km range
|
||||||
- [ ] Use a buffered Go `channel` to pass newly downloaded and parsed `RadarProduct` objects to a central processing component.
|
- [x] NWS active warnings overlay (GeoJSON from `api.weather.gov`, auto-refreshes every 2 min)
|
||||||
- [ ] **Data Mosaicing (`/internal/radar/mosaic.go`):**
|
- [x] Range rings (100 / 200 km) and site markers with tooltips on all 15 sites
|
||||||
- [ ] **Grid Definition:** Define a constant statewide grid in your code. This includes its geographic boundaries (min/max lat/lon), resolution (e.g., 1km x 1km), and dimensions (width/height in pixels).
|
- [x] Loop animation: scrubber + play/pause, 500 ms/frame, builds up to 12 frames (~24 min)
|
||||||
- [ ] **Projection Logic:** Implement robust math functions to convert from (radar, azimuth, range) -> (lat, lon) -> (grid X, grid Y). This is crucial.
|
- [x] REFL / VEL product toggle (velocity infrastructure built; NWS FTP returns 403 for p99r0)
|
||||||
- [ ] **Compositing Engine:**
|
- [x] Leaflet scale bar
|
||||||
- Create a function that runs in a loop, triggered by new data or a timer.
|
|
||||||
- This function initializes a new, empty statewide grid.
|
|
||||||
- It iterates through every cell of the grid. For each cell, it checks all available radars to see which one provides the best data (lowest beam altitude above that grid point).
|
|
||||||
- It then "paints" the data from the best radar onto that grid cell.
|
|
||||||
- [ ] **State Management:** Store the latest completed mosaic grid in memory, protected by a mutex, so it can be safely accessed by the tile-serving handler.
|
|
||||||
- [ ] **Update Visualization Backend:**
|
|
||||||
- [ ] Modify the `/api/v1/tile/...` handler to render tiles from the statewide mosaic grid instead of a single radar product.
|
|
||||||
- [ ] The tile handler no longer needs the radar ID in the URL. New URL: `/api/v1/tile/reflectivity/{z}/{x}/{y}.png`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 3: Advanced Analysis & Alerting
|
## Phase 3: Advanced Analysis & Alerting
|
||||||
|
|
||||||
*Goal: Find the "so what?" in the data. Identify and track dangerous storms.*
|
*Goal: Find the "so what?" — identify, track, and score dangerous storms.*
|
||||||
|
|
||||||
- [ ] **Expand Data Parsing (introduce Level 2):**
|
- [ ] **Storm Cell Identification (`/internal/analysis/segmentation.go`)**
|
||||||
- [ ] Add Level 2 ingestion from the `noaa-nexrad-level2` S3 bucket alongside the existing Level 3 pipeline. Level 2 files are compressed with bzip2 on a per-record basis — implement record-level decompression.
|
- [ ] Flood-fill or connected-components blob detection on composite reflectivity
|
||||||
- [ ] Parse Level 2 Velocity, Differential Reflectivity (ZDR), and Correlation Coefficient (CC) data fields. The official format spec is ICD RDA/RPG 2620002U.
|
- [ ] Output: list of `StormCell` objects with bounding polygon, centroid, max dBZ
|
||||||
- [ ] Update the mosaicing engine to create composite grids for these new data types.
|
- [ ] **Storm Tracking (`/internal/analysis/tracking.go`)**
|
||||||
- [ ] **Storm Cell Identification (`/internal/analysis/segmentation.go`):**
|
- [ ] Frame-to-frame centroid correlation to assign persistent `TrackID`
|
||||||
- [ ] Implement a "blob detection" algorithm on the composite reflectivity grid. This can be a simple algorithm like flood-fill or a more advanced one like DBSCAN.
|
- [ ] Store per-track history: location, time, intensity, motion vector
|
||||||
- [ ] The output should be a list of distinct `StormCell` objects, each with a polygon defining its boundary.
|
- [ ] **Velocity Source**
|
||||||
- [ ] **Signature Detection (`/internal/analysis/signatures.go`):**
|
- [ ] Investigate alternative velocity product paths on NWS FTP (NWS 403 on `DS.p99r0`)
|
||||||
- [ ] For each identified `StormCell`, analyze the underlying data within its polygon.
|
- [ ] Or ingest Level 2 data from `noaa-nexrad-level2` S3 (requester-pays, need creds)
|
||||||
- [ ] Implement a TVS (Tornadic Vortex Signature) detector: Search for strong, compact, inbound/outbound velocity couplets.
|
- [ ] Parse velocity, ZDR, CC fields once a source is confirmed
|
||||||
- [ ] Implement a Hail Core detector: Search for areas of high reflectivity (>55 dBZ) co-located with low CC (< 0.95) and near-zero ZDR.
|
- [ ] **Signature Detection (`/internal/analysis/signatures.go`)**
|
||||||
- [ ] **Storm Tracking (`/internal/analysis/tracking.go`):**
|
- [ ] TVS detector: strong inbound/outbound velocity couplet within a cell
|
||||||
- [ ] Implement a centroid tracking algorithm.
|
- [ ] Hail core detector: dBZ > 55 co-located with low CC and near-zero ZDR
|
||||||
- [ ] For each frame, calculate the center-of-mass for each `StormCell`.
|
- [ ] **Threat Engine (`/internal/analysis/threats.go`)**
|
||||||
- [ ] Correlate cells between frames by finding the closest centroid from the previous frame.
|
- [ ] `Threat` struct: TrackID, type, severity, predicted path GeoJSON
|
||||||
- [ ] Assign a persistent `TrackID` to each storm. Store the storm's history (location, time, intensity) in memory.
|
- [ ] Rules engine: TVS + intensity threshold → tornado threat object
|
||||||
- [ ] **Threat Generation (`/internal/analysis/threats.go`):**
|
- [ ] Extrapolate predicted path from recent motion vector
|
||||||
- [ ] Define a `Threat` struct: `TrackID`, `ThreatType` (Tornado, Hail), `Severity`, `PredictedPath` (a GeoJSON line or polygon).
|
|
||||||
- [ ] Create a rules engine that ingests `StormCell` objects and generates `Threat` objects.
|
|
||||||
- [ ] Example Rule: IF `StormCell.HasTVS == true` AND `StormCell.Intensity > Threshold`, THEN generate a "Tornado Warning" threat object.
|
|
||||||
- [ ] Extrapolate a simple predicted path based on the storm's recent movement vector.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 4: Frontend Refinement with HTMX
|
## Phase 4: Frontend Refinement
|
||||||
|
|
||||||
*Goal: Build a dynamic, interactive, and useful user interface without a heavy JS framework.*
|
*Goal: Make the analysis actionable in the UI.*
|
||||||
|
|
||||||
- [ ] **Create API Endpoints for UI Components:**
|
- [ ] Storm list sidebar (HTMX, refreshes every 15 s)
|
||||||
- [ ] `/ui/storm-list`: Returns an HTML fragment (`<ul>...</ul>`) of currently active tracked storms.
|
- [ ] Storm detail panel on click (centroid, max dBZ, motion, TVS flag)
|
||||||
- [ ] `/ui/storm-detail/{track_id}`: Returns an HTML fragment (`<div>...</div>`) with detailed stats for a specific storm.
|
- [ ] Map overlay: GeoJSON storm polygons + predicted track line
|
||||||
- [ ] **Dynamic UI with HTMX:**
|
- [ ] Pan/zoom to selected storm on sidebar click
|
||||||
- [ ] Create a sidebar on the main page. Use `hx-get="/ui/storm-list"` and `hx-trigger="every 15s"` to keep the list of active storms up-to-date.
|
- [ ] Address search bar with geocoding → drop pin on map
|
||||||
- [ ] Make each item in the storm list clickable. Use `hx-get="/ui/storm-detail/{track_id}"` and `hx-target="#detail-pane"` to load storm details into another panel without a page reload.
|
- [ ] Opacity slider for radar overlay
|
||||||
- [ ] **Map Interactivity:**
|
- [ ] Mobile-responsive layout
|
||||||
- [ ] When a storm detail is loaded, add a GeoJSON layer to the Leaflet map showing its current position and predicted track.
|
|
||||||
- [ ] Use a small vanilla JavaScript `htmx:afterSwap` event listener to command the Leaflet map to pan and zoom to the selected storm's coordinates (passed as `data-` attributes on the swapped HTML fragment).
|
|
||||||
- [ ] Create a search bar that allows a user to input an address. Use an external geocoding API to get coordinates and place a marker on the map.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 5: SRE & Productionization
|
## Phase 5: SRE & Productionization
|
||||||
|
|
||||||
*Goal: Ensure the system is reliable, scalable, and observable, ready for real-world use.*
|
- [ ] Multi-stage `Dockerfile` (build → distroless)
|
||||||
|
- [ ] `docker-compose.yml` with Prometheus scraping `/metrics`
|
||||||
- [ ] **Containerization:**
|
- [ ] Prometheus metrics: `radar_files_processed_total`, `data_latency_seconds`, `active_threats_gauge`
|
||||||
- [ ] Write a multi-stage `Dockerfile`. The first stage builds the Go binary, the second copies the binary into a minimal `distroless` or `alpine` base image for a small, secure result.
|
- [ ] Readiness probe: fail if no radar data ingested in last 10 min
|
||||||
- [ ] **Configuration Management:**
|
- [ ] Alertmanager rules: `RadarFeedDown`, `HighProcessingLatency`, `AppDown`
|
||||||
- [ ] Move all hardcoded settings (radar lists, thresholds) into a configuration file (e.g., `config.yaml`) or environment variables. Use a library like Viper to manage this.
|
- [ ] Config file (`config.yaml` or env vars) for site list, thresholds, ports
|
||||||
- [ ] **Deployment (Docker Compose):**
|
|
||||||
- [ ] Write a `docker-compose.yml` that starts the `heloha-server` container alongside a Prometheus container scraping its `/metrics` endpoint.
|
|
||||||
- [ ] Implement proper liveness (`/healthz`) and readiness probes in your Go server. The readiness check should fail if no radar data has been successfully processed recently.
|
|
||||||
- [ ] *Future path: when ready to scale, migrate to Kubernetes manifests (Deployment, Service, Ingress) packaged with Helm. The Dockerfile and `/healthz`/readiness endpoints you build now translate directly.*
|
|
||||||
- [ ] **Observability:**
|
|
||||||
- [ ] **Metrics:** Add the `prometheus/client_golang` library. Instrument your code to export key metrics:
|
|
||||||
- `heloha_radar_files_processed_total{radar="KTLX", status="success"}`
|
|
||||||
- `heloha_data_latency_seconds` (time from file timestamp to processing completion)
|
|
||||||
- `heloha_active_threats_gauge`
|
|
||||||
- [ ] **Logging:** Use Go's stdlib `slog` with JSON output. Log key events (new threat detected, radar feed lost, tile cache stats).
|
|
||||||
- [ ] **Alerting:** Configure Prometheus and Alertmanager (included in the Compose stack). Create critical alerts:
|
|
||||||
- `ALERT RadarFeedDown IF (time() - last_success_timestamp{radar="KTLX"}) > 600`
|
|
||||||
- `ALERT HighProcessingLatency`
|
|
||||||
- `ALERT AppDown (based on container health check)`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Backlog / Future Ideas
|
## Backlog / Future Ideas
|
||||||
|
|
||||||
- [ ] **Mesonet Integration:** Add another data ingestion pipeline for the Oklahoma Mesonet. Correlate ground-level wind speed/pressure drops with radar signatures.
|
- [ ] Oklahoma Mesonet integration (ground-level wind, pressure, temp)
|
||||||
- [ ] **Database Persistence:** Store historical storm tracks and threat polygons in a time-series or geospatial database (e.g., TimescaleDB, PostGIS).
|
- [ ] Database persistence for storm tracks (TimescaleDB or PostGIS)
|
||||||
- [ ] **Push Notifications:** Implement a system (e.g., WebSockets or a mobile app) to send real-time alerts to users based on their registered location.
|
- [ ] WebSocket push notifications for threats near a user's location
|
||||||
- [ ] **Machine Learning:** Train a model on historical radar data and confirmed tornado reports to create a more accurate probabilistic threat model.
|
- [ ] ML model trained on historical radar + confirmed tornado reports
|
||||||
|
- [ ] Animate warning polygon borders (pulsing CSS) for active tornado warnings
|
||||||
|
- [ ] Multi-tilt display (0.5°, 1.5°, 2.5°) — currently fixed at 0.5°
|
||||||
|
|||||||
@@ -1,17 +1,69 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/blakeridgway/heloha/internal/radar"
|
||||||
|
"github.com/blakeridgway/heloha/internal/server"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ingestEvery = 2 * time.Minute
|
||||||
|
|
||||||
|
var radarSites = []string{
|
||||||
|
"KTLX", // Oklahoma City, OK
|
||||||
|
"KINX", // Tulsa, OK
|
||||||
|
"KVNX", // Vance AFB, OK
|
||||||
|
"KFDR", // Frederick, OK
|
||||||
|
"KSRX", // Fort Smith, AR
|
||||||
|
"KLZK", // Little Rock, AR
|
||||||
|
"KSHV", // Shreveport, LA
|
||||||
|
"KFWS", // Fort Worth, TX
|
||||||
|
"KDYX", // Dyess AFB, TX
|
||||||
|
"KAMA", // Amarillo, TX
|
||||||
|
"KLBB", // Lubbock, TX
|
||||||
|
"KICT", // Wichita, KS
|
||||||
|
"KDDC", // Dodge City, KS
|
||||||
|
"KSGF", // Springfield, MO
|
||||||
|
"KEAX", // Kansas City, MO
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
fetcher, err := radar.NewFetcher(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("init fetcher", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := server.NewRadarStore()
|
||||||
|
cache := server.NewTileCache()
|
||||||
|
|
||||||
|
ingestAll(ctx, logger, fetcher, store, cache)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
tick := time.NewTicker(ingestEvery)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
ingestAll(ctx, logger, fetcher, store, cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
indexTmpl := template.Must(template.ParseFiles("web/templates/index.html"))
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
@@ -23,6 +75,31 @@ func main() {
|
|||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
indexTmpl.Execute(w, nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Composite tile: product + frame in path (new)
|
||||||
|
r.Get("/api/v1/tile/composite/{product}/{frame}/{z}/{x}/{y}.png", server.CompositeTileHandler(store, cache))
|
||||||
|
// Legacy composite route (product=reflectivity, frame=latest)
|
||||||
|
r.Get("/api/v1/tile/composite/{z}/{x}/{y}.png", server.CompositeTileHandler(store, cache))
|
||||||
|
// Per-site tile
|
||||||
|
r.Get("/api/v1/tile/{site}/{z}/{x}/{y}.png", server.TileHandler(store, cache))
|
||||||
|
|
||||||
|
// Frame metadata for the loop player
|
||||||
|
r.Get("/api/v1/frames", server.FramesHandler(store))
|
||||||
|
|
||||||
|
// Scan time (KTLX reference)
|
||||||
|
r.Get("/api/v1/scan-time", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := store.GetLatest("ktlx", "reflectivity")
|
||||||
|
if p == nil {
|
||||||
|
w.Write([]byte("—"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write([]byte(p.Time.Format("15:04 UTC")))
|
||||||
|
})
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":8080",
|
Addr: ":8080",
|
||||||
Handler: r,
|
Handler: r,
|
||||||
@@ -37,3 +114,54 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ingestAll fetches reflectivity + velocity for every site concurrently.
|
||||||
|
func ingestAll(ctx context.Context, logger *slog.Logger, f *radar.Fetcher, store *server.RadarStore, cache *server.TileCache) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, site := range radarSites {
|
||||||
|
wg.Add(2)
|
||||||
|
go func(s string) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := ingestRefl(ctx, logger, f, store, s); err != nil {
|
||||||
|
logger.Warn("refl ingest failed", "site", s, "err", err)
|
||||||
|
}
|
||||||
|
}(site)
|
||||||
|
go func(s string) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := ingestVel(ctx, logger, f, store, s); err != nil {
|
||||||
|
logger.Warn("vel ingest failed", "site", s, "err", err)
|
||||||
|
}
|
||||||
|
}(site)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
cache.Invalidate()
|
||||||
|
logger.Info("ingest cycle complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ingestRefl(ctx context.Context, logger *slog.Logger, f *radar.Fetcher, store *server.RadarStore, site string) error {
|
||||||
|
data, err := f.FetchLatest(ctx, site)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p, err := radar.Parse(site, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store.Set(site, "reflectivity", p)
|
||||||
|
logger.Info("refl ok", "site", site, "time", p.Time, "radials", len(p.Radials))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ingestVel(ctx context.Context, logger *slog.Logger, f *radar.Fetcher, store *server.RadarStore, site string) error {
|
||||||
|
data, err := f.FetchVelocity(ctx, site)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p, err := radar.ParseVelocity(site, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store.Set(site, "velocity", p)
|
||||||
|
logger.Info("vel ok", "site", site, "time", p.Time)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
51
internal/radar/fetch.go
Normal file
51
internal/radar/fetch.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package radar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nwsRadarBase is the NWS public product distribution server.
|
||||||
|
// No credentials required; sn.last always holds the most recent scan.
|
||||||
|
const nwsRadarBase = "https://tgftp.nws.noaa.gov/SL.us008001/DF.of/DC.radar"
|
||||||
|
|
||||||
|
// Fetcher downloads NEXRAD Level 3 products from the NWS public server.
|
||||||
|
type Fetcher struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFetcher(_ context.Context) (*Fetcher, error) {
|
||||||
|
return &Fetcher{client: &http.Client{Timeout: 30 * time.Second}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLatest downloads the latest Level 3 base reflectivity (0.5°) for site.
|
||||||
|
func (f *Fetcher) FetchLatest(ctx context.Context, site string) ([]byte, error) {
|
||||||
|
return f.fetchDS(ctx, site, "p94r0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchVelocity downloads the latest Level 3 base velocity (0.5°) for site.
|
||||||
|
func (f *Fetcher) FetchVelocity(ctx context.Context, site string) ([]byte, error) {
|
||||||
|
return f.fetchDS(ctx, site, "p99r0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) fetchDS(ctx context.Context, site, ds string) ([]byte, error) {
|
||||||
|
url := fmt.Sprintf("%s/DS.%s/SI.%s/sn.last", nwsRadarBase, ds, strings.ToLower(site))
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := f.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("NWS fetch: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("NWS returned %s for %s", resp.Status, url)
|
||||||
|
}
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
230
internal/radar/parse.go
Normal file
230
internal/radar/parse.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package radar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/bzip2"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RadarProduct holds parsed data from one Level 3 scan.
|
||||||
|
type RadarProduct struct {
|
||||||
|
Site string
|
||||||
|
Product string // "reflectivity" or "velocity"
|
||||||
|
SiteLat float64
|
||||||
|
SiteLon float64
|
||||||
|
Elevation float64
|
||||||
|
Time time.Time
|
||||||
|
Radials []Radial
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radial is one spoke of radar data.
|
||||||
|
type Radial struct {
|
||||||
|
Azimuth float64
|
||||||
|
DeltaAz float64
|
||||||
|
RangeKm float64
|
||||||
|
BinSizeKm float64
|
||||||
|
Values []float32 // dBZ per bin; NaN = no data
|
||||||
|
}
|
||||||
|
|
||||||
|
// GateLL returns the lat/lon center of range bin i in this radial.
|
||||||
|
func (r *Radial) GateLL(siteLat, siteLon float64, i int) (lat, lon float64) {
|
||||||
|
distKm := r.RangeKm + float64(i)*r.BinSizeKm + r.BinSizeKm/2
|
||||||
|
return rangeBearing(siteLat, siteLon, distKm, r.Azimuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse decodes a NEXRAD Level 3 base reflectivity product (N0Q / product 94).
|
||||||
|
func Parse(site string, data []byte) (*RadarProduct, error) {
|
||||||
|
return parseProduct(site, "reflectivity", data, func(v byte) float32 {
|
||||||
|
if v <= 1 {
|
||||||
|
return float32(math.NaN())
|
||||||
|
}
|
||||||
|
return float32(v-2)*0.5 - 32.0 // dBZ
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseVelocity decodes a NEXRAD Level 3 base velocity product (N0U / product 99).
|
||||||
|
func ParseVelocity(site string, data []byte) (*RadarProduct, error) {
|
||||||
|
return parseProduct(site, "velocity", data, func(v byte) float32 {
|
||||||
|
if v <= 1 {
|
||||||
|
return float32(math.NaN())
|
||||||
|
}
|
||||||
|
return float32(v-2)*0.5 - 63.5 // m/s
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseProduct(site, productType string, data []byte, decode func(byte) float32) (*RadarProduct, error) {
|
||||||
|
data = stripWMOHeader(data)
|
||||||
|
if len(data) < 120 {
|
||||||
|
return nil, fmt.Errorf("file too short after header strip (%d bytes)", len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Message Header Block (18 bytes) — skip ---
|
||||||
|
|
||||||
|
// --- Product Description Block (starts at byte 18) ---
|
||||||
|
pdb := data[18:]
|
||||||
|
if len(pdb) < 102 {
|
||||||
|
return nil, fmt.Errorf("product description block truncated")
|
||||||
|
}
|
||||||
|
|
||||||
|
siteLat := float64(int32(binary.BigEndian.Uint32(pdb[2:]))) / 1000.0
|
||||||
|
siteLon := float64(int32(binary.BigEndian.Uint32(pdb[6:]))) / 1000.0
|
||||||
|
volDate := int16(binary.BigEndian.Uint16(pdb[22:]))
|
||||||
|
volTimeSec := int32(binary.BigEndian.Uint32(pdb[24:])) // seconds since midnight
|
||||||
|
scanTime := julianToTime(volDate, volTimeSec)
|
||||||
|
|
||||||
|
// data[120:] is the Symbology Block, bzip2-compressed for digital products (N0Q).
|
||||||
|
// 18 bytes Message Header + 102 bytes PDB = 120 bytes prefix.
|
||||||
|
symData := data[120:]
|
||||||
|
if len(symData) >= 3 && symData[0] == 'B' && symData[1] == 'Z' && symData[2] == 'h' {
|
||||||
|
r := bzip2.NewReader(bytes.NewReader(symData))
|
||||||
|
decompressed, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decompress symbology: %w", err)
|
||||||
|
}
|
||||||
|
symData = decompressed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the Symbology Block by scanning for its signature:
|
||||||
|
// block divider (FF FF) + block ID (00 01).
|
||||||
|
symPos := -1
|
||||||
|
for i := 0; i+3 < len(symData); i++ {
|
||||||
|
if symData[i] == 0xFF && symData[i+1] == 0xFF && symData[i+2] == 0x00 && symData[i+3] == 0x01 {
|
||||||
|
symPos = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if symPos < 0 {
|
||||||
|
return nil, fmt.Errorf("symbology block not found in decompressed data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Symbology Block header (10 bytes) ---
|
||||||
|
sym := symData[symPos:]
|
||||||
|
// [0-1]: block divider (-1)
|
||||||
|
// [2-3]: block ID (1)
|
||||||
|
// [4-7]: block length (bytes)
|
||||||
|
// [8-9]: number of layers
|
||||||
|
|
||||||
|
// --- Layer header (6 bytes) ---
|
||||||
|
if 10+6 > len(sym) {
|
||||||
|
return nil, fmt.Errorf("symbology block too short for layer")
|
||||||
|
}
|
||||||
|
layer := sym[10:]
|
||||||
|
// [0-1]: layer divider (-1)
|
||||||
|
// [2-5]: layer length (bytes)
|
||||||
|
|
||||||
|
pkt := layer[6:]
|
||||||
|
packetCode := int16(binary.BigEndian.Uint16(pkt[0:]))
|
||||||
|
if packetCode != 16 {
|
||||||
|
return nil, fmt.Errorf("unsupported packet code %d (want 16)", packetCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Packet Code 16 header (14 bytes) ---
|
||||||
|
// [0-1]: code (16)
|
||||||
|
// [2-3]: first range bin index
|
||||||
|
// [4-5]: number of range bins
|
||||||
|
// [6-7]: I center
|
||||||
|
// [8-9]: J center
|
||||||
|
// [10-11]: scale factor (thousandths of km per pixel)
|
||||||
|
// [12-13]: number of radials
|
||||||
|
numBins := int(int16(binary.BigEndian.Uint16(pkt[4:])))
|
||||||
|
scaleFactor := int(int16(binary.BigEndian.Uint16(pkt[10:])))
|
||||||
|
numRadials := int(int16(binary.BigEndian.Uint16(pkt[12:])))
|
||||||
|
|
||||||
|
binSizeKm := 1.0
|
||||||
|
if scaleFactor > 0 {
|
||||||
|
binSizeKm = float64(scaleFactor) / 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pos := 14 // offset into pkt
|
||||||
|
radials := make([]Radial, 0, numRadials)
|
||||||
|
for i := 0; i < numRadials; i++ {
|
||||||
|
if pos+6 > len(pkt) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
runBytes := int(int16(binary.BigEndian.Uint16(pkt[pos:])))
|
||||||
|
startAngle := float64(int16(binary.BigEndian.Uint16(pkt[pos+2:]))) / 10.0
|
||||||
|
deltaAngle := float64(int16(binary.BigEndian.Uint16(pkt[pos+4:]))) / 10.0
|
||||||
|
pos += 6
|
||||||
|
|
||||||
|
if pos+runBytes > len(pkt) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
raw := pkt[pos : pos+runBytes]
|
||||||
|
pos += runBytes
|
||||||
|
// Pad to even-byte boundary.
|
||||||
|
if runBytes%2 != 0 {
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make([]float32, numBins)
|
||||||
|
for j := 0; j < numBins && j < len(raw); j++ {
|
||||||
|
values[j] = decode(raw[j])
|
||||||
|
}
|
||||||
|
|
||||||
|
radials = append(radials, Radial{
|
||||||
|
Azimuth: startAngle,
|
||||||
|
DeltaAz: deltaAngle,
|
||||||
|
RangeKm: 0.0,
|
||||||
|
BinSizeKm: binSizeKm,
|
||||||
|
Values: values,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(radials) == 0 {
|
||||||
|
return nil, fmt.Errorf("no radials parsed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RadarProduct{
|
||||||
|
Site: site,
|
||||||
|
Product: productType,
|
||||||
|
SiteLat: siteLat,
|
||||||
|
SiteLon: siteLon,
|
||||||
|
Elevation: 0.5,
|
||||||
|
Time: scanTime,
|
||||||
|
Radials: radials,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripWMOHeader removes the variable-length WMO/AFOS text header that NWS
|
||||||
|
// prepends to distributed products. The binary NEXRAD data begins after the
|
||||||
|
// final \r\r\n sequence in the first 100 bytes.
|
||||||
|
func stripWMOHeader(data []byte) []byte {
|
||||||
|
limit := 100
|
||||||
|
if len(data) < limit {
|
||||||
|
limit = len(data)
|
||||||
|
}
|
||||||
|
last := 0
|
||||||
|
for i := 0; i+2 < limit; i++ {
|
||||||
|
if data[i] == '\r' && data[i+1] == '\r' && data[i+2] == '\n' {
|
||||||
|
last = i + 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data[last:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func julianToTime(julianDate int16, secs int32) time.Time {
|
||||||
|
base := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
d := base.AddDate(0, 0, int(julianDate)-1)
|
||||||
|
return d.Add(time.Duration(secs) * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rangeBearing(lat0, lon0, distKm, bearing float64) (lat, lon float64) {
|
||||||
|
const earthR = 6371.0
|
||||||
|
lat0r := lat0 * math.Pi / 180
|
||||||
|
lon0r := lon0 * math.Pi / 180
|
||||||
|
br := bearing * math.Pi / 180
|
||||||
|
dr := distKm / earthR
|
||||||
|
|
||||||
|
latr := math.Asin(math.Sin(lat0r)*math.Cos(dr) +
|
||||||
|
math.Cos(lat0r)*math.Sin(dr)*math.Cos(br))
|
||||||
|
lonr := lon0r + math.Atan2(
|
||||||
|
math.Sin(br)*math.Sin(dr)*math.Cos(lat0r),
|
||||||
|
math.Cos(dr)-math.Sin(lat0r)*math.Sin(latr),
|
||||||
|
)
|
||||||
|
return latr * 180 / math.Pi, lonr * 180 / math.Pi
|
||||||
|
}
|
||||||
|
|
||||||
390
internal/server/handlers.go
Normal file
390
internal/server/handlers.go
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/blakeridgway/heloha/internal/radar"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tileSize = 256
|
||||||
|
|
||||||
|
// TileHandler serves /api/v1/tile/{site}/{z}/{x}/{y}.png (single-site, reflectivity).
|
||||||
|
func TileHandler(store *RadarStore, cache *TileCache) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
site := strings.ToLower(chi.URLParam(r, "site"))
|
||||||
|
z, err1 := strconv.Atoi(chi.URLParam(r, "z"))
|
||||||
|
x, err2 := strconv.Atoi(chi.URLParam(r, "x"))
|
||||||
|
y, err3 := strconv.Atoi(chi.URLParam(r, "y"))
|
||||||
|
if err1 != nil || err2 != nil || err3 != nil {
|
||||||
|
http.Error(w, "invalid tile coords", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := site + "/refl/latest/" + strconv.Itoa(z) + "/" + strconv.Itoa(x) + "/" + strconv.Itoa(y)
|
||||||
|
if data, ok := cache.Get(key); ok {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
w.Write(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
product := store.GetLatest(site, "reflectivity")
|
||||||
|
if product == nil {
|
||||||
|
http.Error(w, "no radar data", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
img := renderTile(product, z, x, y, dbzColor)
|
||||||
|
writeTile(w, cache, key, img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompositeTileHandler serves:
|
||||||
|
//
|
||||||
|
// /api/v1/tile/composite/{product}/{frame}/{z}/{x}/{y}.png
|
||||||
|
// /api/v1/tile/composite/{z}/{x}/{y}.png (legacy: product=reflectivity, frame=latest)
|
||||||
|
func CompositeTileHandler(store *RadarStore, cache *TileCache) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
product := chi.URLParam(r, "product")
|
||||||
|
if product == "" {
|
||||||
|
product = "reflectivity"
|
||||||
|
}
|
||||||
|
frameStr := chi.URLParam(r, "frame")
|
||||||
|
|
||||||
|
z, err1 := strconv.Atoi(chi.URLParam(r, "z"))
|
||||||
|
x, err2 := strconv.Atoi(chi.URLParam(r, "x"))
|
||||||
|
y, err3 := strconv.Atoi(chi.URLParam(r, "y"))
|
||||||
|
if err1 != nil || err2 != nil || err3 != nil {
|
||||||
|
http.Error(w, "invalid tile coords", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fmt.Sprintf("composite/%s/%s/%d/%d/%d", product, frameStr, z, x, y)
|
||||||
|
if data, ok := cache.Get(key); ok {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
w.Write(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var products []*radar.RadarProduct
|
||||||
|
if frameStr == "" || frameStr == "latest" {
|
||||||
|
products = store.GetAllLatest(product)
|
||||||
|
} else {
|
||||||
|
frameIdx, err := strconv.Atoi(frameStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid frame", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
products = store.GetAllAtFrame(product, frameIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(products) == 0 {
|
||||||
|
http.Error(w, "no radar data", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
colorFn := dbzColor
|
||||||
|
if product == "velocity" {
|
||||||
|
colorFn = velColor
|
||||||
|
}
|
||||||
|
|
||||||
|
img := renderCompositeTile(products, z, x, y, colorFn)
|
||||||
|
writeTile(w, cache, key, img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FramesHandler serves /api/v1/frames?product=reflectivity|velocity
|
||||||
|
func FramesHandler(store *RadarStore) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
product := r.URL.Query().Get("product")
|
||||||
|
if product == "" {
|
||||||
|
product = "reflectivity"
|
||||||
|
}
|
||||||
|
count, frames := store.FrameMeta(product)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"product": product,
|
||||||
|
"frame_count": count,
|
||||||
|
"frames": frames,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// speckleFilter removes pixels with fewer than minNeighbors non-transparent
|
||||||
|
// 8-connected neighbors. Running multiple passes progressively erodes small
|
||||||
|
// noise clusters while large coherent precipitation areas survive intact.
|
||||||
|
func speckleFilter(src *image.RGBA) *image.RGBA {
|
||||||
|
const (
|
||||||
|
passes = 4
|
||||||
|
minNeighbors = 4
|
||||||
|
)
|
||||||
|
img := src
|
||||||
|
for pass := 0; pass < passes; pass++ {
|
||||||
|
dst := image.NewRGBA(img.Bounds())
|
||||||
|
for y := 0; y < tileSize; y++ {
|
||||||
|
for x := 0; x < tileSize; x++ {
|
||||||
|
c := img.RGBAAt(x, y)
|
||||||
|
if c.A == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for dy := -1; dy <= 1; dy++ {
|
||||||
|
for dx := -1; dx <= 1; dx++ {
|
||||||
|
if dx == 0 && dy == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nx, ny := x+dx, y+dy
|
||||||
|
if nx >= 0 && nx < tileSize && ny >= 0 && ny < tileSize &&
|
||||||
|
img.RGBAAt(nx, ny).A > 0 {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n >= minNeighbors {
|
||||||
|
dst.SetRGBA(x, y, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img = dst
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTile(w http.ResponseWriter, cache *TileCache, key string, img *image.RGBA) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
http.Error(w, "encode error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := buf.Bytes()
|
||||||
|
cache.Set(key, data)
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCompositeTile builds a mosaic tile: each pixel gets a value from the
|
||||||
|
// nearest radar site within 230 km, colored by colorFn.
|
||||||
|
func renderCompositeTile(products []*radar.RadarProduct, z, x, y int, colorFn func(float32) color.RGBA) *image.RGBA {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, tileSize, tileSize))
|
||||||
|
|
||||||
|
north, west := tileToLatLon(z, x, y)
|
||||||
|
south, east := tileToLatLon(z, x+1, y+1)
|
||||||
|
latSpan := north - south
|
||||||
|
lonSpan := east - west
|
||||||
|
|
||||||
|
const maxRangeKm = 230.0
|
||||||
|
|
||||||
|
for py := 0; py < tileSize; py++ {
|
||||||
|
lat := north - float64(py)/tileSize*latSpan
|
||||||
|
for px := 0; px < tileSize; px++ {
|
||||||
|
lon := west + float64(px)/tileSize*lonSpan
|
||||||
|
|
||||||
|
var bestVal float32 = float32(math.NaN())
|
||||||
|
bestDist := math.MaxFloat64
|
||||||
|
|
||||||
|
for _, p := range products {
|
||||||
|
bearing, distKm := bearingDist(p.SiteLat, p.SiteLon, lat, lon)
|
||||||
|
if distKm >= maxRangeKm || distKm >= bestDist {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val := interpolateValue(p.Radials, bearing, distKm)
|
||||||
|
bestDist = distKm
|
||||||
|
bestVal = val
|
||||||
|
}
|
||||||
|
|
||||||
|
if math.IsNaN(float64(bestVal)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
img.SetRGBA(px, py, colorFn(bestVal))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return speckleFilter(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTile renders a single-site tile.
|
||||||
|
func renderTile(p *radar.RadarProduct, z, x, y int, colorFn func(float32) color.RGBA) *image.RGBA {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, tileSize, tileSize))
|
||||||
|
|
||||||
|
north, west := tileToLatLon(z, x, y)
|
||||||
|
south, east := tileToLatLon(z, x+1, y+1)
|
||||||
|
latSpan := north - south
|
||||||
|
lonSpan := east - west
|
||||||
|
|
||||||
|
for py := 0; py < tileSize; py++ {
|
||||||
|
lat := north - float64(py)/tileSize*latSpan
|
||||||
|
for px := 0; px < tileSize; px++ {
|
||||||
|
lon := west + float64(px)/tileSize*lonSpan
|
||||||
|
bearing, distKm := bearingDist(p.SiteLat, p.SiteLon, lat, lon)
|
||||||
|
val := interpolateValue(p.Radials, bearing, distKm)
|
||||||
|
if math.IsNaN(float64(val)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
img.SetRGBA(px, py, colorFn(val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return speckleFilter(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
// interpolateValue bilinearly interpolates between the two nearest radials and adjacent bins.
|
||||||
|
func interpolateValue(radials []radar.Radial, bearing, distKm float64) float32 {
|
||||||
|
if len(radials) == 0 {
|
||||||
|
return float32(math.NaN())
|
||||||
|
}
|
||||||
|
|
||||||
|
best := 0
|
||||||
|
bestDiff := azDiff(radials[0].Azimuth, bearing)
|
||||||
|
for i := 1; i < len(radials); i++ {
|
||||||
|
if d := azDiff(radials[i].Azimuth, bearing); d < bestDiff {
|
||||||
|
bestDiff = d
|
||||||
|
best = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diff := bearing - radials[best].Azimuth
|
||||||
|
if diff > 180 {
|
||||||
|
diff -= 360
|
||||||
|
}
|
||||||
|
if diff < -180 {
|
||||||
|
diff += 360
|
||||||
|
}
|
||||||
|
|
||||||
|
var adj int
|
||||||
|
if diff >= 0 {
|
||||||
|
adj = (best + 1) % len(radials)
|
||||||
|
} else {
|
||||||
|
adj = (best - 1 + len(radials)) % len(radials)
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
|
||||||
|
span := azDiff(radials[best].Azimuth, radials[adj].Azimuth)
|
||||||
|
t := 0.0
|
||||||
|
if span > 0 {
|
||||||
|
t = math.Min(diff/span, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
v1 := radialValueAt(&radials[best], distKm)
|
||||||
|
v2 := radialValueAt(&radials[adj], distKm)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case math.IsNaN(float64(v1)) && math.IsNaN(float64(v2)):
|
||||||
|
return float32(math.NaN())
|
||||||
|
case math.IsNaN(float64(v1)):
|
||||||
|
return v2
|
||||||
|
case math.IsNaN(float64(v2)):
|
||||||
|
return v1
|
||||||
|
}
|
||||||
|
return float32(float64(v1)*(1-t) + float64(v2)*t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func radialValueAt(r *radar.Radial, distKm float64) float32 {
|
||||||
|
if distKm < r.RangeKm || r.BinSizeKm == 0 {
|
||||||
|
return float32(math.NaN())
|
||||||
|
}
|
||||||
|
exactBin := (distKm - r.RangeKm) / r.BinSizeKm
|
||||||
|
b0 := int(exactBin)
|
||||||
|
if b0 >= len(r.Values) {
|
||||||
|
return float32(math.NaN())
|
||||||
|
}
|
||||||
|
v0 := r.Values[b0]
|
||||||
|
b1 := b0 + 1
|
||||||
|
if b1 >= len(r.Values) || math.IsNaN(float64(v0)) {
|
||||||
|
return v0
|
||||||
|
}
|
||||||
|
v1 := r.Values[b1]
|
||||||
|
if math.IsNaN(float64(v1)) {
|
||||||
|
return v0
|
||||||
|
}
|
||||||
|
frac := float32(exactBin - float64(b0))
|
||||||
|
return v0*(1-frac) + v1*frac
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearingDist(lat1, lon1, lat2, lon2 float64) (bearing, distKm float64) {
|
||||||
|
const earthR = 6371.0
|
||||||
|
lat1r := lat1 * math.Pi / 180
|
||||||
|
lat2r := lat2 * math.Pi / 180
|
||||||
|
dlat := (lat2 - lat1) * math.Pi / 180
|
||||||
|
dlon := (lon2 - lon1) * math.Pi / 180
|
||||||
|
a := math.Sin(dlat/2)*math.Sin(dlat/2) +
|
||||||
|
math.Cos(lat1r)*math.Cos(lat2r)*math.Sin(dlon/2)*math.Sin(dlon/2)
|
||||||
|
distKm = earthR * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||||
|
y := math.Sin(dlon) * math.Cos(lat2r)
|
||||||
|
x := math.Cos(lat1r)*math.Sin(lat2r) - math.Sin(lat1r)*math.Cos(lat2r)*math.Cos(dlon)
|
||||||
|
bearing = math.Atan2(y, x) * 180 / math.Pi
|
||||||
|
if bearing < 0 {
|
||||||
|
bearing += 360
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func azDiff(a, b float64) float64 {
|
||||||
|
d := math.Abs(a - b)
|
||||||
|
if d > 180 {
|
||||||
|
d = 360 - d
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func tileToLatLon(z, x, y int) (lat, lon float64) {
|
||||||
|
n := math.Pow(2, float64(z))
|
||||||
|
lon = float64(x)/n*360.0 - 180.0
|
||||||
|
latRad := math.Atan(math.Sinh(math.Pi * (1 - 2*float64(y)/n)))
|
||||||
|
lat = latRad * 180.0 / math.Pi
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// dbzColor maps dBZ to RGBA. Below 20 dBZ is transparent — removes AP,
|
||||||
|
// biological returns, and ground clutter while keeping real precipitation.
|
||||||
|
func dbzColor(dbz float32) color.RGBA {
|
||||||
|
switch {
|
||||||
|
case dbz < 20:
|
||||||
|
return color.RGBA{0, 0, 0, 0}
|
||||||
|
case dbz < 25:
|
||||||
|
return color.RGBA{0x02, 0x8e, 0x00, 210}
|
||||||
|
case dbz < 30:
|
||||||
|
return color.RGBA{0x01, 0xb4, 0x4c, 210}
|
||||||
|
case dbz < 35:
|
||||||
|
return color.RGBA{0x9c, 0xe4, 0x00, 220}
|
||||||
|
case dbz < 40:
|
||||||
|
return color.RGBA{0xd8, 0xd8, 0x00, 220}
|
||||||
|
case dbz < 45:
|
||||||
|
return color.RGBA{0xff, 0xaa, 0x00, 230}
|
||||||
|
case dbz < 50:
|
||||||
|
return color.RGBA{0xff, 0x00, 0x00, 230}
|
||||||
|
case dbz < 55:
|
||||||
|
return color.RGBA{0xd0, 0x00, 0x00, 240}
|
||||||
|
case dbz < 60:
|
||||||
|
return color.RGBA{0xc0, 0x00, 0x80, 240}
|
||||||
|
default:
|
||||||
|
return color.RGBA{0xff, 0xf7, 0x00, 255}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// velColor maps radial velocity (m/s) to RGBA.
|
||||||
|
// Negative = toward radar (green), positive = away (red).
|
||||||
|
func velColor(ms float32) color.RGBA {
|
||||||
|
switch {
|
||||||
|
case ms <= -27:
|
||||||
|
return color.RGBA{0xff, 0x00, 0x00, 230} // strong inbound
|
||||||
|
case ms <= -14:
|
||||||
|
return color.RGBA{0xff, 0x66, 0x66, 210}
|
||||||
|
case ms <= -1:
|
||||||
|
return color.RGBA{0xff, 0xaa, 0xaa, 180}
|
||||||
|
case ms < 1:
|
||||||
|
return color.RGBA{0, 0, 0, 0} // near-zero
|
||||||
|
case ms < 14:
|
||||||
|
return color.RGBA{0xaa, 0xff, 0xaa, 180}
|
||||||
|
case ms < 27:
|
||||||
|
return color.RGBA{0x00, 0xcc, 0x00, 210}
|
||||||
|
default:
|
||||||
|
return color.RGBA{0x00, 0x66, 0x00, 230} // strong outbound
|
||||||
|
}
|
||||||
|
}
|
||||||
134
internal/server/store.go
Normal file
134
internal/server/store.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/blakeridgway/heloha/internal/radar"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxFrames = 12
|
||||||
|
|
||||||
|
type productKey struct{ site, product string }
|
||||||
|
|
||||||
|
type frameRing struct {
|
||||||
|
frames [maxFrames]*radar.RadarProduct
|
||||||
|
head int // next write slot
|
||||||
|
count int // filled slots (0..maxFrames)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *frameRing) push(p *radar.RadarProduct) {
|
||||||
|
// Deduplicate: skip if same or older scan time as the newest stored frame.
|
||||||
|
if r.count > 0 {
|
||||||
|
newest := r.frames[(r.head-1+maxFrames)%maxFrames]
|
||||||
|
if !newest.Time.Before(p.Time) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.frames[r.head] = p
|
||||||
|
r.head = (r.head + 1) % maxFrames
|
||||||
|
if r.count < maxFrames {
|
||||||
|
r.count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns the frame at index i where 0=oldest, count-1=newest.
|
||||||
|
func (r *frameRing) get(i int) *radar.RadarProduct {
|
||||||
|
if i < 0 || i >= r.count {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slot := (r.head - r.count + i + maxFrames) % maxFrames
|
||||||
|
return r.frames[slot]
|
||||||
|
}
|
||||||
|
|
||||||
|
// RadarStore holds multi-site, multi-product frame history.
|
||||||
|
type RadarStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
rings map[productKey]*frameRing
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRadarStore() *RadarStore {
|
||||||
|
return &RadarStore{rings: make(map[productKey]*frameRing)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RadarStore) Set(site, product string, p *radar.RadarProduct) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
k := productKey{site: site, product: product}
|
||||||
|
if s.rings[k] == nil {
|
||||||
|
s.rings[k] = &frameRing{}
|
||||||
|
}
|
||||||
|
s.rings[k].push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatest returns the newest frame for a given site and product, or nil.
|
||||||
|
func (s *RadarStore) GetLatest(site, product string) *radar.RadarProduct {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
k := productKey{site: site, product: product}
|
||||||
|
r := s.rings[k]
|
||||||
|
if r == nil || r.count == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.get(r.count - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllLatest returns the newest frame for every site for the given product.
|
||||||
|
func (s *RadarStore) GetAllLatest(product string) []*radar.RadarProduct {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]*radar.RadarProduct, 0, 16)
|
||||||
|
for k, r := range s.rings {
|
||||||
|
if k.product == product && r.count > 0 {
|
||||||
|
out = append(out, r.get(r.count-1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllAtFrame returns one frame per site at the given age index (0=oldest, N-1=newest).
|
||||||
|
func (s *RadarStore) GetAllAtFrame(product string, frameIdx int) []*radar.RadarProduct {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]*radar.RadarProduct, 0, 16)
|
||||||
|
for k, r := range s.rings {
|
||||||
|
if k.product != product {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Map frameIdx relative to this ring's count.
|
||||||
|
// frameIdx 0 = oldest across all rings → use offset from back.
|
||||||
|
p := r.get(frameIdx)
|
||||||
|
if p != nil {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrameInfo is one entry in the frames API response.
|
||||||
|
type FrameInfo struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
AgeSeconds int `json:"age_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrameMeta returns frame metadata ordered oldest→newest using KTLX as the reference.
|
||||||
|
func (s *RadarStore) FrameMeta(product string) (int, []FrameInfo) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
r := s.rings[productKey{site: "ktlx", product: product}]
|
||||||
|
if r == nil || r.count == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
out := make([]FrameInfo, r.count)
|
||||||
|
for i := 0; i < r.count; i++ {
|
||||||
|
p := r.get(i)
|
||||||
|
out[i] = FrameInfo{
|
||||||
|
Index: i,
|
||||||
|
Time: p.Time,
|
||||||
|
AgeSeconds: int(now.Sub(p.Time).Seconds()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.count, out
|
||||||
|
}
|
||||||
34
internal/server/tilecache.go
Normal file
34
internal/server/tilecache.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// TileCache is a thread-safe in-memory cache for rendered PNG tiles.
|
||||||
|
// Keys are "z/x/y". The entire cache is invalidated on each new radar scan.
|
||||||
|
type TileCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
tiles map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTileCache() *TileCache {
|
||||||
|
return &TileCache{tiles: make(map[string][]byte)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TileCache) Get(key string) ([]byte, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
v, ok := c.tiles[key]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TileCache) Set(key string, data []byte) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.tiles[key] = data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate clears all cached tiles. Call this after each new radar product is loaded.
|
||||||
|
func (c *TileCache) Invalidate() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.tiles = make(map[string][]byte)
|
||||||
|
}
|
||||||
414
web/templates/index.html
Normal file
414
web/templates/index.html
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Heloha — NEXRAD Radar</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: #0d1117; color: #e6edf3; font-family: 'SF Mono', 'Fira Mono', monospace; }
|
||||||
|
#map { width: 100vw; height: 100vh; }
|
||||||
|
|
||||||
|
/* ── shared panel style ── */
|
||||||
|
.panel {
|
||||||
|
background: rgba(13,17,23,0.90);
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top HUD ── */
|
||||||
|
#hud {
|
||||||
|
position: absolute; top: 14px; left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 7px 18px;
|
||||||
|
font-size: 12px; color: #8b949e;
|
||||||
|
white-space: nowrap; letter-spacing: 0.02em;
|
||||||
|
display: flex; align-items: center; gap: 0;
|
||||||
|
}
|
||||||
|
#hud .site { color: #58a6ff; font-weight: 700; font-size: 13px; }
|
||||||
|
#hud .label { color: #c9d1d9; }
|
||||||
|
#hud .hi { color: #3fb950; font-weight: 600; }
|
||||||
|
#hud .sep { color: #30363d; margin: 0 10px; user-select: none; }
|
||||||
|
|
||||||
|
/* ── Loop player ── */
|
||||||
|
#player {
|
||||||
|
position: absolute; bottom: 36px; left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 8px 14px;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
#player button {
|
||||||
|
background: none; border: 1px solid #30363d;
|
||||||
|
color: #c9d1d9; border-radius: 5px;
|
||||||
|
padding: 4px 10px; font: 12px monospace;
|
||||||
|
cursor: pointer; transition: border-color .15s, color .15s;
|
||||||
|
}
|
||||||
|
#player button:hover { border-color: #58a6ff; color: #58a6ff; }
|
||||||
|
#player button.active { border-color: #58a6ff; color: #58a6ff; background: rgba(88,166,255,0.08); }
|
||||||
|
#player button:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
#scrubber {
|
||||||
|
width: 180px; accent-color: #58a6ff; cursor: pointer;
|
||||||
|
}
|
||||||
|
#frame-time { font-size: 11px; color: #8b949e; min-width: 60px; }
|
||||||
|
#live-badge {
|
||||||
|
font-size: 10px; font-weight: 700; letter-spacing: .06em;
|
||||||
|
color: #3fb950; border: 1px solid #3fb950;
|
||||||
|
border-radius: 4px; padding: 1px 5px;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
#live-badge.hidden { display: none; }
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
|
||||||
|
|
||||||
|
/* ── dBZ Legend ── */
|
||||||
|
#legend, #vel-legend {
|
||||||
|
position: absolute; bottom: 36px; left: 16px;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 11px; color: #8b949e;
|
||||||
|
pointer-events: none; min-width: 108px;
|
||||||
|
}
|
||||||
|
#vel-legend { display: none; }
|
||||||
|
.legend-title {
|
||||||
|
color: #c9d1d9; font-size: 10px; font-weight: 700;
|
||||||
|
letter-spacing: .08em; text-transform: uppercase; margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
.legend-row { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
|
||||||
|
.swatch { width: 22px; height: 10px; border-radius: 2px; flex-shrink: 0; }
|
||||||
|
.lbl { color: #8b949e; font-size: 10px; }
|
||||||
|
|
||||||
|
/* ── NWS Warning tooltips ── */
|
||||||
|
.warn-tip {
|
||||||
|
background: rgba(13,17,23,0.92) !important;
|
||||||
|
border: 1px solid #30363d !important;
|
||||||
|
border-radius: 6px !important; color: #c9d1d9 !important;
|
||||||
|
font: 11px/1.5 monospace !important;
|
||||||
|
box-shadow: none !important; padding: 5px 9px !important;
|
||||||
|
}
|
||||||
|
.warn-tip::before { display: none !important; }
|
||||||
|
|
||||||
|
/* ── Radar site tooltips ── */
|
||||||
|
.radar-tip {
|
||||||
|
background: rgba(13,17,23,0.92) !important;
|
||||||
|
border: 1px solid #30363d !important;
|
||||||
|
border-radius: 6px !important; color: #c9d1d9 !important;
|
||||||
|
font: 11px/1.5 monospace !important;
|
||||||
|
box-shadow: none !important; padding: 5px 9px !important;
|
||||||
|
}
|
||||||
|
.radar-tip b { color: #58a6ff; }
|
||||||
|
.radar-tip::before { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Top HUD -->
|
||||||
|
<div id="hud" class="panel">
|
||||||
|
<span class="site">15 NEXRAD</span>
|
||||||
|
<span class="sep">|</span>
|
||||||
|
<span class="label" id="product-label">Base Reflectivity 0.5°</span>
|
||||||
|
<span class="sep">|</span>
|
||||||
|
<span class="label">KTLX </span><span class="hi" id="scan-time">—</span>
|
||||||
|
<span class="sep">|</span>
|
||||||
|
<span class="hi" id="utc-clock">--:-- UTC</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span class="label" id="cst-clock">--:-- CDT</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loop player -->
|
||||||
|
<div id="player" class="panel">
|
||||||
|
<button id="btn-refl" class="active" onclick="setProduct('reflectivity')">REFL</button>
|
||||||
|
<button id="btn-vel" onclick="setProduct('velocity')">VEL</button>
|
||||||
|
<span class="sep" style="margin:0 2px"></span>
|
||||||
|
<button id="btn-play" onclick="playToggle()">▶</button>
|
||||||
|
<input id="scrubber" type="range" min="0" max="0" step="1" value="0"
|
||||||
|
oninput="onScrub(this.value)">
|
||||||
|
<span id="live-badge">LIVE</span>
|
||||||
|
<span id="frame-time"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reflectivity legend -->
|
||||||
|
<div id="legend" class="panel">
|
||||||
|
<div class="legend-title">dBZ</div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#028e00"></div><span class="lbl">20 – 25</span></div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#01b44c"></div><span class="lbl">25 – 30</span></div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#9ce400"></div><span class="lbl">30 – 35</span></div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#d8d800"></div><span class="lbl">35 – 40</span></div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#ffaa00"></div><span class="lbl">40 – 45</span></div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#ff0000"></div><span class="lbl">45 – 50</span></div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#d00000"></div><span class="lbl">50 – 55</span></div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#c00080"></div><span class="lbl">55 – 60</span></div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#fff700"></div><span class="lbl">> 60</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Velocity legend -->
|
||||||
|
<div id="vel-legend" class="panel">
|
||||||
|
<div class="legend-title">m/s</div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#ff0000"></div><span class="lbl">≤ −27 inbound</span></div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#ff6666"></div><span class="lbl">−27 – −14</span></div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#ffaaaa"></div><span class="lbl">−14 – −1</span></div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#333;border:1px solid #444"></div><span class="lbl">near zero</span></div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#aaffaa"></div><span class="lbl">+1 – +14</span></div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#00cc00"></div><span class="lbl">+14 – +27</span></div>
|
||||||
|
<div class="legend-row"><div class="swatch" style="background:#006600"></div><span class="lbl">≥ +27 outbound</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── NEXRAD site list ──────────────────────────────────────────────────────
|
||||||
|
const nexradSites = [
|
||||||
|
{ id: 'KTLX', name: 'Oklahoma City, OK', lat: 35.3331, lon: -97.2778 },
|
||||||
|
{ id: 'KINX', name: 'Tulsa, OK', lat: 36.1750, lon: -95.5647 },
|
||||||
|
{ id: 'KVNX', name: 'Vance AFB, OK', lat: 36.7408, lon: -98.1278 },
|
||||||
|
{ id: 'KFDR', name: 'Frederick, OK', lat: 34.3622, lon: -98.9764 },
|
||||||
|
{ id: 'KSRX', name: 'Fort Smith, AR', lat: 35.2906, lon: -94.3619 },
|
||||||
|
{ id: 'KLZK', name: 'Little Rock, AR', lat: 34.8364, lon: -92.2622 },
|
||||||
|
{ id: 'KSHV', name: 'Shreveport, LA', lat: 32.4508, lon: -93.8411 },
|
||||||
|
{ id: 'KFWS', name: 'Fort Worth, TX', lat: 32.5728, lon: -97.3028 },
|
||||||
|
{ id: 'KDYX', name: 'Dyess AFB, TX', lat: 32.5386, lon: -99.2539 },
|
||||||
|
{ id: 'KAMA', name: 'Amarillo, TX', lat: 35.2333, lon: -101.709 },
|
||||||
|
{ id: 'KLBB', name: 'Lubbock, TX', lat: 33.6542, lon: -101.814 },
|
||||||
|
{ id: 'KICT', name: 'Wichita, KS', lat: 37.6545, lon: -97.4433 },
|
||||||
|
{ id: 'KDDC', name: 'Dodge City, KS', lat: 37.7608, lon: -99.9689 },
|
||||||
|
{ id: 'KSGF', name: 'Springfield, MO', lat: 37.2353, lon: -93.4008 },
|
||||||
|
{ id: 'KEAX', name: 'Kansas City, MO', lat: 38.8100, lon: -94.2644 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Map init ─────────────────────────────────────────────────────────────
|
||||||
|
const map = L.map('map', { center: [36.0, -97.5], zoom: 7, zoomControl: true });
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors © CARTO',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
|
const state = {
|
||||||
|
product: 'reflectivity',
|
||||||
|
frameCount: 0,
|
||||||
|
currentFrame: -1, // -1 = live (always latest)
|
||||||
|
playing: false,
|
||||||
|
playTimer: null,
|
||||||
|
frames: [], // [{index, time, age_seconds}] oldest→newest
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Radar tile layer ──────────────────────────────────────────────────────
|
||||||
|
let radarLayer = null;
|
||||||
|
|
||||||
|
function tileUrl() {
|
||||||
|
const frame = state.currentFrame === -1 ? 'latest' : state.currentFrame;
|
||||||
|
return `/api/v1/tile/composite/${state.product}/${frame}/{z}/{x}/{y}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshLayer() {
|
||||||
|
if (radarLayer) map.removeLayer(radarLayer);
|
||||||
|
radarLayer = L.tileLayer(tileUrl(), {
|
||||||
|
opacity: 0.85, maxZoom: 19, updateWhenIdle: false,
|
||||||
|
}).addTo(map);
|
||||||
|
updateFrameTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFrameTime() {
|
||||||
|
const el = document.getElementById('frame-time');
|
||||||
|
const badge = document.getElementById('live-badge');
|
||||||
|
if (state.currentFrame === -1 || state.frames.length === 0) {
|
||||||
|
el.textContent = '';
|
||||||
|
badge.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
const f = state.frames[state.currentFrame];
|
||||||
|
if (f) {
|
||||||
|
const t = new Date(f.time);
|
||||||
|
el.textContent = t.toLocaleTimeString('en-US', {
|
||||||
|
timeZone: 'UTC', hour: '2-digit', minute: '2-digit', hour12: false,
|
||||||
|
}) + ' UTC';
|
||||||
|
}
|
||||||
|
badge.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Product toggle ────────────────────────────────────────────────────────
|
||||||
|
function setProduct(p) {
|
||||||
|
state.product = p;
|
||||||
|
state.currentFrame = -1;
|
||||||
|
stopPlay();
|
||||||
|
document.getElementById('btn-refl').classList.toggle('active', p === 'reflectivity');
|
||||||
|
document.getElementById('btn-vel').classList.toggle('active', p === 'velocity');
|
||||||
|
document.getElementById('product-label').textContent =
|
||||||
|
p === 'reflectivity' ? 'Base Reflectivity 0.5°' : 'Base Velocity 0.5°';
|
||||||
|
document.getElementById('legend').style.display = p === 'reflectivity' ? '' : 'none';
|
||||||
|
document.getElementById('vel-legend').style.display = p === 'velocity' ? '' : 'none';
|
||||||
|
fetchFrames().then(() => refreshLayer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scrubber / frame nav ──────────────────────────────────────────────────
|
||||||
|
function onScrub(val) {
|
||||||
|
stopPlay();
|
||||||
|
const i = parseInt(val);
|
||||||
|
state.currentFrame = i < state.frameCount ? i : -1;
|
||||||
|
refreshLayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScrubber() {
|
||||||
|
const s = document.getElementById('scrubber');
|
||||||
|
s.max = Math.max(0, state.frameCount - 1);
|
||||||
|
s.value = state.currentFrame === -1 ? s.max : state.currentFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Playback ──────────────────────────────────────────────────────────────
|
||||||
|
function playToggle() {
|
||||||
|
state.playing ? stopPlay() : startPlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPlay() {
|
||||||
|
if (state.frameCount < 2) return;
|
||||||
|
state.playing = true;
|
||||||
|
document.getElementById('btn-play').textContent = '⏸';
|
||||||
|
// Start from oldest if currently live
|
||||||
|
if (state.currentFrame === -1) state.currentFrame = 0;
|
||||||
|
state.playTimer = setInterval(() => {
|
||||||
|
state.currentFrame = (state.currentFrame + 1) % state.frameCount;
|
||||||
|
updateScrubber();
|
||||||
|
refreshLayer();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPlay() {
|
||||||
|
state.playing = false;
|
||||||
|
document.getElementById('btn-play').textContent = '▶';
|
||||||
|
if (state.playTimer) { clearInterval(state.playTimer); state.playTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frame metadata ────────────────────────────────────────────────────────
|
||||||
|
async function fetchFrames() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/v1/frames?product=${state.product}`);
|
||||||
|
const d = await r.json();
|
||||||
|
state.frameCount = d.frame_count || 0;
|
||||||
|
state.frames = d.frames || [];
|
||||||
|
updateScrubber();
|
||||||
|
const velBtn = document.getElementById('btn-vel');
|
||||||
|
if (state.product === 'reflectivity') {
|
||||||
|
// check if vel has any frames
|
||||||
|
const vr = await fetch('/api/v1/frames?product=velocity');
|
||||||
|
const vd = await vr.json();
|
||||||
|
velBtn.disabled = (vd.frame_count || 0) === 0;
|
||||||
|
velBtn.title = velBtn.disabled ? 'Velocity not available from this source' : '';
|
||||||
|
}
|
||||||
|
} catch(e) { console.warn('frames fetch failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scan time ─────────────────────────────────────────────────────────────
|
||||||
|
async function fetchScanTime() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/v1/scan-time');
|
||||||
|
document.getElementById('scan-time').textContent = await r.text();
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UTC / CDT clock ───────────────────────────────────────────────────────
|
||||||
|
function updateClock() {
|
||||||
|
const now = new Date();
|
||||||
|
document.getElementById('utc-clock').textContent =
|
||||||
|
now.toLocaleTimeString('en-US', { timeZone: 'UTC', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) + ' UTC';
|
||||||
|
const tzName = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Chicago', timeZoneName: 'short' })
|
||||||
|
.formatToParts(now).find(p => p.type === 'timeZoneName')?.value || 'CT';
|
||||||
|
document.getElementById('cst-clock').textContent =
|
||||||
|
now.toLocaleTimeString('en-US', { timeZone: 'America/Chicago', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) + ' ' + tzName;
|
||||||
|
}
|
||||||
|
setInterval(updateClock, 1000);
|
||||||
|
updateClock();
|
||||||
|
|
||||||
|
// ── NWS Active Warnings ───────────────────────────────────────────────────
|
||||||
|
const alertColors = {
|
||||||
|
'Tornado Warning': '#FF0000',
|
||||||
|
'Severe Thunderstorm Warning': '#FFA500',
|
||||||
|
'Tornado Watch': '#FFFF00',
|
||||||
|
'Severe Thunderstorm Watch': '#DB7093',
|
||||||
|
'Flash Flood Warning': '#00FF00',
|
||||||
|
'Flash Flood Watch': '#2E8B57',
|
||||||
|
'Special Weather Statement': '#FFE4B5',
|
||||||
|
};
|
||||||
|
|
||||||
|
function alertColor(event) {
|
||||||
|
for (const [k, v] of Object.entries(alertColors)) {
|
||||||
|
if (event && event.includes(k)) return v;
|
||||||
|
}
|
||||||
|
return '#AAAAAA';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedicated pane above radar tiles (default overlay pane is z=400; we use 450)
|
||||||
|
map.createPane('warnings');
|
||||||
|
map.getPane('warnings').style.zIndex = 450;
|
||||||
|
map.getPane('warnings').style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
let warningLayer = null;
|
||||||
|
async function fetchWarnings() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('https://api.weather.gov/alerts/active?area=OK,TX,KS,AR,MO,LA,CO,NM');
|
||||||
|
const d = await r.json();
|
||||||
|
if (warningLayer) map.removeLayer(warningLayer);
|
||||||
|
warningLayer = L.geoJSON(d, {
|
||||||
|
filter: f => f.geometry !== null,
|
||||||
|
pane: 'warnings',
|
||||||
|
style: f => {
|
||||||
|
const c = alertColor(f.properties.event);
|
||||||
|
return { color: c, weight: 2.5, opacity: 1, fillColor: c, fillOpacity: 0.30, dashArray: '6 4' };
|
||||||
|
},
|
||||||
|
onEachFeature: (f, layer) => {
|
||||||
|
layer.bindTooltip(
|
||||||
|
`<b>${f.properties.event}</b><br>${f.properties.areaDesc || ''}`,
|
||||||
|
{ sticky: true, opacity: 0.95, className: 'warn-tip' }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}).addTo(map);
|
||||||
|
} catch(e) { console.warn('warnings fetch failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Range rings + site markers ────────────────────────────────────────────
|
||||||
|
nexradSites.forEach(site => {
|
||||||
|
[100, 200].forEach(km => {
|
||||||
|
L.circle([site.lat, site.lon], {
|
||||||
|
radius: km * 1000, color: '#fff', weight: 0.4,
|
||||||
|
opacity: 0.10, fill: false, interactive: false,
|
||||||
|
}).addTo(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
L.circleMarker([site.lat, site.lon], {
|
||||||
|
radius: 3, color: '#fff', fillColor: '#fff', fillOpacity: 0.9, weight: 1,
|
||||||
|
})
|
||||||
|
.bindTooltip(`<b>${site.id}</b><br>${site.name}`, {
|
||||||
|
direction: 'top', offset: [0, -6], opacity: 0.92, className: 'radar-tip',
|
||||||
|
})
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
L.marker([site.lat, site.lon], {
|
||||||
|
interactive: false,
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<span style="color:rgba(255,255,255,0.6);font:9px/1 monospace;padding-left:6px;white-space:nowrap">${site.id}</span>`,
|
||||||
|
iconAnchor: [-2, 4],
|
||||||
|
}),
|
||||||
|
}).addTo(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Scale bar ─────────────────────────────────────────────────────────────
|
||||||
|
L.control.scale({ imperial: true, metric: true, position: 'bottomright' }).addTo(map);
|
||||||
|
|
||||||
|
// ── Boot ──────────────────────────────────────────────────────────────────
|
||||||
|
fetchFrames().then(() => refreshLayer());
|
||||||
|
fetchWarnings();
|
||||||
|
fetchScanTime();
|
||||||
|
|
||||||
|
setInterval(fetchFrames, 120_000);
|
||||||
|
setInterval(fetchWarnings, 120_000);
|
||||||
|
setInterval(fetchScanTime, 60_000);
|
||||||
|
// Auto-refresh live layer every 60 s when not looping
|
||||||
|
setInterval(() => { if (!state.playing && state.currentFrame === -1) refreshLayer(); }, 60_000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user