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