diff --git a/TODO.md b/TODO.md index 1bff5be..5606685 100644 --- a/TODO.md +++ b/TODO.md @@ -1,169 +1,103 @@ # 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] **Initialize Git Repository:** - - [x] `git init` - - [x] Create a `.gitignore` file for Go and common OS files. - - [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` +- [x] Git repo, `.gitignore`, `README.md` +- [x] Go module (`github.com/blakeridgway/heloha`), standard project layout +- [x] `go-chi/chi` HTTP router with middleware +- [x] `/healthz` endpoint +- [x] `Makefile` with `build`, `run`, `test`, `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`):** - - [ ] Add the AWS SDK for Go V2 (`aws-sdk-go-v2`) as a dependency. - - [ ] **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. - - [ ] 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. - - [ ] Write a function to download a specific Level 3 file from S3 into memory. - - [ ] *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.* -- [ ] **Data Parsing (`/internal/radar/parse.go`):** - - [ ] 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. - - [ ] 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. +- [x] Fetch latest Level 3 N0Q (Digital Base Reflectivity 0.5°) via HTTP from NWS FTP +- [x] Parse NEXRAD Level 3 ICD-2620001 binary (WMO header strip, bzip2 Symbology Block, Packet 16 radials) +- [x] Web Mercator tile renderer with bilinear interpolation between radials and range bins +- [x] In-memory tile cache (`sync.RWMutex` map), invalidated on each ingest cycle +- [x] Leaflet.js frontend centered on Oklahoma, dark CartoDB basemap +- [x] HUD with site label, scan time, UTC + CDT live clock +- [x] dBZ color legend +- [x] 20 dBZ minimum threshold + 4-pass speckle filter to suppress AP/biological noise --- -## 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`):** - - [ ] Design a concurrent manager that spawns one "fetcher" goroutine per radar (KTLX, KINX, KFDR, KVNX, etc.). - - [ ] Each fetcher goroutine periodically checks for new data for its assigned radar. - - [ ] Use a buffered Go `channel` to pass newly downloaded and parsed `RadarProduct` objects to a central processing component. -- [ ] **Data Mosaicing (`/internal/radar/mosaic.go`):** - - [ ] **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). - - [ ] **Projection Logic:** Implement robust math functions to convert from (radar, azimuth, range) -> (lat, lon) -> (grid X, grid Y). This is crucial. - - [ ] **Compositing Engine:** - - 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`. +- [x] 15 NEXRAD sites (OK, TX, KS, AR, MO, LA) ingested concurrently every 2 minutes +- [x] Per-site, per-product ring buffer (12 frames of history) +- [x] Composite tile endpoint: nearest radar per pixel within 230 km range +- [x] NWS active warnings overlay (GeoJSON from `api.weather.gov`, auto-refreshes every 2 min) +- [x] Range rings (100 / 200 km) and site markers with tooltips on all 15 sites +- [x] Loop animation: scrubber + play/pause, 500 ms/frame, builds up to 12 frames (~24 min) +- [x] REFL / VEL product toggle (velocity infrastructure built; NWS FTP returns 403 for p99r0) +- [x] Leaflet scale bar --- ## 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):** - - [ ] 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. - - [ ] Parse Level 2 Velocity, Differential Reflectivity (ZDR), and Correlation Coefficient (CC) data fields. The official format spec is ICD RDA/RPG 2620002U. - - [ ] Update the mosaicing engine to create composite grids for these new data types. -- [ ] **Storm Cell Identification (`/internal/analysis/segmentation.go`):** - - [ ] 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. - - [ ] The output should be a list of distinct `StormCell` objects, each with a polygon defining its boundary. -- [ ] **Signature Detection (`/internal/analysis/signatures.go`):** - - [ ] For each identified `StormCell`, analyze the underlying data within its polygon. - - [ ] Implement a TVS (Tornadic Vortex Signature) detector: Search for strong, compact, inbound/outbound velocity couplets. - - [ ] Implement a Hail Core detector: Search for areas of high reflectivity (>55 dBZ) co-located with low CC (< 0.95) and near-zero ZDR. -- [ ] **Storm Tracking (`/internal/analysis/tracking.go`):** - - [ ] Implement a centroid tracking algorithm. - - [ ] For each frame, calculate the center-of-mass for each `StormCell`. - - [ ] Correlate cells between frames by finding the closest centroid from the previous frame. - - [ ] Assign a persistent `TrackID` to each storm. Store the storm's history (location, time, intensity) in memory. -- [ ] **Threat Generation (`/internal/analysis/threats.go`):** - - [ ] 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. +- [ ] **Storm Cell Identification (`/internal/analysis/segmentation.go`)** + - [ ] Flood-fill or connected-components blob detection on composite reflectivity + - [ ] Output: list of `StormCell` objects with bounding polygon, centroid, max dBZ +- [ ] **Storm Tracking (`/internal/analysis/tracking.go`)** + - [ ] Frame-to-frame centroid correlation to assign persistent `TrackID` + - [ ] Store per-track history: location, time, intensity, motion vector +- [ ] **Velocity Source** + - [ ] Investigate alternative velocity product paths on NWS FTP (NWS 403 on `DS.p99r0`) + - [ ] Or ingest Level 2 data from `noaa-nexrad-level2` S3 (requester-pays, need creds) + - [ ] Parse velocity, ZDR, CC fields once a source is confirmed +- [ ] **Signature Detection (`/internal/analysis/signatures.go`)** + - [ ] TVS detector: strong inbound/outbound velocity couplet within a cell + - [ ] Hail core detector: dBZ > 55 co-located with low CC and near-zero ZDR +- [ ] **Threat Engine (`/internal/analysis/threats.go`)** + - [ ] `Threat` struct: TrackID, type, severity, predicted path GeoJSON + - [ ] Rules engine: TVS + intensity threshold → tornado threat object + - [ ] Extrapolate predicted path from recent motion 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:** - - [ ] `/ui/storm-list`: Returns an HTML fragment (``) of currently active tracked storms. - - [ ] `/ui/storm-detail/{track_id}`: Returns an HTML fragment (`
...
`) with detailed stats for a specific storm. -- [ ] **Dynamic UI with HTMX:** - - [ ] 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. - - [ ] 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. -- [ ] **Map Interactivity:** - - [ ] 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. +- [ ] Storm list sidebar (HTMX, refreshes every 15 s) +- [ ] Storm detail panel on click (centroid, max dBZ, motion, TVS flag) +- [ ] Map overlay: GeoJSON storm polygons + predicted track line +- [ ] Pan/zoom to selected storm on sidebar click +- [ ] Address search bar with geocoding → drop pin on map +- [ ] Opacity slider for radar overlay +- [ ] Mobile-responsive layout --- ## Phase 5: SRE & Productionization -*Goal: Ensure the system is reliable, scalable, and observable, ready for real-world use.* - -- [ ] **Containerization:** - - [ ] 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. -- [ ] **Configuration Management:** - - [ ] 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. -- [ ] **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)` +- [ ] Multi-stage `Dockerfile` (build → distroless) +- [ ] `docker-compose.yml` with Prometheus scraping `/metrics` +- [ ] Prometheus metrics: `radar_files_processed_total`, `data_latency_seconds`, `active_threats_gauge` +- [ ] Readiness probe: fail if no radar data ingested in last 10 min +- [ ] Alertmanager rules: `RadarFeedDown`, `HighProcessingLatency`, `AppDown` +- [ ] Config file (`config.yaml` or env vars) for site list, thresholds, ports --- ## Backlog / Future Ideas -- [ ] **Mesonet Integration:** Add another data ingestion pipeline for the Oklahoma Mesonet. Correlate ground-level wind speed/pressure drops with radar signatures. -- [ ] **Database Persistence:** Store historical storm tracks and threat polygons in a time-series or geospatial database (e.g., TimescaleDB, 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. -- [ ] **Machine Learning:** Train a model on historical radar data and confirmed tornado reports to create a more accurate probabilistic threat model. +- [ ] Oklahoma Mesonet integration (ground-level wind, pressure, temp) +- [ ] Database persistence for storm tracks (TimescaleDB or PostGIS) +- [ ] WebSocket push notifications for threats near a user's location +- [ ] 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° diff --git a/cmd/heloha-server/main.go b/cmd/heloha-server/main.go index 5ff7e39..2ca67f7 100644 --- a/cmd/heloha-server/main.go +++ b/cmd/heloha-server/main.go @@ -1,17 +1,69 @@ package main import ( + "context" + "html/template" "log/slog" "net/http" "os" + "sync" "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/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() { 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.Use(middleware.RequestID) @@ -23,6 +75,31 @@ func main() { 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{ Addr: ":8080", Handler: r, @@ -37,3 +114,54 @@ func main() { 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 +} diff --git a/internal/radar/fetch.go b/internal/radar/fetch.go new file mode 100644 index 0000000..447db4d --- /dev/null +++ b/internal/radar/fetch.go @@ -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) +} diff --git a/internal/radar/parse.go b/internal/radar/parse.go new file mode 100644 index 0000000..03386b0 --- /dev/null +++ b/internal/radar/parse.go @@ -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 +} + diff --git a/internal/server/handlers.go b/internal/server/handlers.go new file mode 100644 index 0000000..2612d4b --- /dev/null +++ b/internal/server/handlers.go @@ -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 + } +} diff --git a/internal/server/store.go b/internal/server/store.go new file mode 100644 index 0000000..b166278 --- /dev/null +++ b/internal/server/store.go @@ -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 +} diff --git a/internal/server/tilecache.go b/internal/server/tilecache.go new file mode 100644 index 0000000..7685e1e --- /dev/null +++ b/internal/server/tilecache.go @@ -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) +} diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..529139c --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,414 @@ + + + + + + Heloha — NEXRAD Radar + + + + + + +
+ 15 NEXRAD + | + Base Reflectivity 0.5° + | + KTLX + | + --:-- UTC + · + --:-- CDT +
+ + +
+ + + + + + LIVE + +
+ + +
+
dBZ
+
20 – 25
+
25 – 30
+
30 – 35
+
35 – 40
+
40 – 45
+
45 – 50
+
50 – 55
+
55 – 60
+
> 60
+
+ + +
+
m/s
+
≤ −27 inbound
+
−27 – −14
+
−14 – −1
+
near zero
+
+1 – +14
+
+14 – +27
+
≥ +27 outbound
+
+ +
+ + + + +