Skip to content

Commit 773a97a

Browse files
committed
first cut of weather
1 parent 8f9210c commit 773a97a

8 files changed

Lines changed: 1878 additions & 8 deletions

File tree

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,35 @@ With the default `draft = 6 ft`:
5555
| `0 – 2×draft` | DEPVS | saturated blue |
5656
| `≥ 2×draft` | DEPDW | white (safe water) |
5757

58+
### Optional wind overlay
59+
60+
The chartplotter can show animated wind particles using
61+
[sakitam-fdd/wind-layer](https://github.com/sakitam-fdd/wind-layer). It's wired
62+
up as a togglable layer (off by default) and appears in the layers panel as
63+
`wind` once the package is installed and wind data is published.
64+
65+
Wind data is served by the bundled NOAA weather cache at
66+
`/noaa-weather/gfs/latest.json` (see below). The cache fetches the latest
67+
GFS 0.25° UGRD/VGRD at 10 m above ground from NOMADS, parses the GRIB2
68+
inline, and writes the JSON shape `ol-wind` consumes. No external
69+
converter required — `grib2json`, ecCodes, Java, etc. are *not* needed.
70+
71+
To enable:
72+
73+
1. `npm install` (the `ol-wind` package is already listed in `package.json`).
74+
2. Rebuild the frontend (`npm run build`) and reload — toggle `wind` on
75+
from the layers panel.
76+
77+
If `ol-wind` is missing or NOMADS is unreachable the chartplotter logs a
78+
warning and leaves the layer out — nothing else breaks.
79+
80+
### NOAA weather endpoints
81+
82+
| Path | Purpose |
83+
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
84+
| `/noaa-weather/gfs/latest.json` | Latest GFS 0.25° UGRD + VGRD at 10 m, two-record JSON shaped for `ol-wind`. Disk-cached under `<root>/noaa-weather/`, soft TTL 90 min with stale-while-revalidate. Fetches the most recent published GFS cycle from NOMADS (walks back in 6 h steps until one returns 200). |
85+
| `/noaa-weather/stats` | JSON cache stats: hits, refreshes, errors, current file size and mtime. |
86+
5887
### Debug endpoints
5988

6089
| Path | Purpose |

module.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"net/http"
1212
"os"
1313
"path/filepath"
14+
"strings"
1415

1516
"go.viam.com/rdk/components/generic"
1617
"go.viam.com/rdk/logging"
@@ -68,6 +69,51 @@ func resolveCacheRoot(configured string) string {
6869
return filepath.Join(base, "viam-chartplotter")
6970
}
7071

72+
// withCookiePathRoot wraps an http.Handler so any Set-Cookie headers
73+
// it writes that don't already specify a Path get `Path=/` appended.
74+
// Required because vmodutils's cookie middleware doesn't set Path
75+
// and Go's default-Path-from-request-URL behaviour fans out the same
76+
// cookie into a copy per tile path.
77+
func withCookiePathRoot(next http.Handler) http.Handler {
78+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
79+
next.ServeHTTP(&cookiePathRootWriter{ResponseWriter: w}, r)
80+
})
81+
}
82+
83+
type cookiePathRootWriter struct {
84+
http.ResponseWriter
85+
wroteHeader bool
86+
}
87+
88+
func (w *cookiePathRootWriter) fixCookies() {
89+
cookies := w.Header().Values("Set-Cookie")
90+
if len(cookies) == 0 {
91+
return
92+
}
93+
w.Header().Del("Set-Cookie")
94+
for _, c := range cookies {
95+
if !strings.Contains(strings.ToLower(c), "path=") {
96+
c = c + "; Path=/"
97+
}
98+
w.Header().Add("Set-Cookie", c)
99+
}
100+
}
101+
102+
func (w *cookiePathRootWriter) WriteHeader(code int) {
103+
if !w.wroteHeader {
104+
w.wroteHeader = true
105+
w.fixCookies()
106+
}
107+
w.ResponseWriter.WriteHeader(code)
108+
}
109+
110+
func (w *cookiePathRootWriter) Write(b []byte) (int, error) {
111+
if !w.wroteHeader {
112+
w.WriteHeader(http.StatusOK)
113+
}
114+
return w.ResponseWriter.Write(b)
115+
}
116+
71117
// StartChartplotterServer wires the static frontend, the NOAA WMS caching proxy, and
72118
// the ENC catalog/store handlers, and starts an HTTP server on the given port.
73119
// draftFt is the boat's draft in feet — drives the depth-shading bands at
@@ -87,6 +133,15 @@ func StartChartplotterServer(
87133
if err != nil {
88134
return nil, err
89135
}
136+
// vmodutils.PrepInModuleServer installs a cookie middleware that
137+
// calls http.SetCookie(w, &http.Cookie{Name, Value}) without
138+
// setting Path — Go then fills in the request URL's directory as
139+
// the default Path. That means every tile URL gets its own copy of
140+
// `api-key` / `api-key-id` / `host` cookies, fanning out into
141+
// hundreds of duplicates per session. Wrap the server handler so
142+
// any outgoing Set-Cookie gets a global Path=/ if it doesn't
143+
// already specify one.
144+
server.Handler = withCookiePathRoot(server.Handler)
90145

91146
root := resolveCacheRoot(cacheRoot)
92147

@@ -128,6 +183,18 @@ func StartChartplotterServer(
128183
encHandlers.Register(mux)
129184
logger.Infof("noaa enc store: %s (default draft=%.1f ft)", encDir, draftFt)
130185

186+
// NOAA GFS weather cache. Serves /noaa-weather/gfs/latest.json which
187+
// the frontend wind layer (ol-wind) consumes. Disk cache lives under
188+
// <root>/noaa-weather/.
189+
weatherDir := filepath.Join(root, "noaa-weather")
190+
weatherCache, err := NewWeatherCache(weatherDir, logger.Sublogger("weather"))
191+
if err != nil {
192+
logger.Warnf("weather cache disabled: %v", err)
193+
} else {
194+
weatherCache.Register(mux)
195+
logger.Infof("noaa weather cache: %s", weatherDir)
196+
}
197+
131198
// Per-process instance ID. The frontend polls /version and reloads when it
132199
// changes, so the browser picks up a new build/restart without manual refresh.
133200
instanceID := newInstanceID()

0 commit comments

Comments
 (0)