From 303b5d452dff870844aa03cda9b1065a07e51e29 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 12:33:20 +0200 Subject: [PATCH] feat: show which area a shared GPS position falls in on the Wardriving tab Adds AreaForPoint (config.go), picking the most specific configured area when several overlap, and wires it into the GPS Sharing table as a badge next to each sender's shared position. Co-Authored-By: Claude Sonnet 5 --- cmd/server/config.go | 45 ++++++++++++++++++++++ cmd/server/config_test.go | 57 ++++++++++++++++++++++++++++ cmd/server/routes.go | 8 ++++ cmd/server/types.go | 3 ++ cmd/server/wardriving_stats_test.go | 58 +++++++++++++++++++++++++++++ public/analytics.js | 6 ++- test-analytics-wardriving-tab.js | 17 +++++++++ 7 files changed, 193 insertions(+), 1 deletion(-) diff --git a/cmd/server/config.go b/cmd/server/config.go index 4d852958..0f713b90 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "log" + "math" "os" "path/filepath" "strings" @@ -31,6 +32,50 @@ type AreaEntry struct { RegionScope string `json:"regionScope,omitempty"` } +// AreaForPoint returns the label of the most specific configured area that +// contains (lat, lon), preferring the smallest matching area when several +// nested areas overlap (e.g. a point inside both "Odense by" and "Fyn"). +// Returns ok=false for (0,0)/no-fix points or when no area matches. +func AreaForPoint(lat, lon float64, areas map[string]AreaEntry) (label string, ok bool) { + if lat == 0 && lon == 0 { + return "", false + } + bestSpan := math.MaxFloat64 + for _, a := range areas { + gf := &geofilter.Config{Polygon: a.Polygon, LatMin: a.LatMin, LatMax: a.LatMax, LonMin: a.LonMin, LonMax: a.LonMax} + if !geofilter.PassesFilter(lat, lon, gf) { + continue + } + span := areaSpan(a) + if span < bestSpan { + bestSpan = span + label = a.Label + ok = true + } + } + return label, ok +} + +// areaSpan approximates an area's size as its bounding-box extent in +// degrees², used only to rank overlapping areas from most to least specific. +func areaSpan(a AreaEntry) float64 { + var latMin, latMax, lonMin, lonMax float64 + switch { + case len(a.Polygon) > 0: + latMin, latMax = a.Polygon[0][0], a.Polygon[0][0] + lonMin, lonMax = a.Polygon[0][1], a.Polygon[0][1] + for _, p := range a.Polygon { + latMin, latMax = math.Min(latMin, p[0]), math.Max(latMax, p[0]) + lonMin, lonMax = math.Min(lonMin, p[1]), math.Max(lonMax, p[1]) + } + case a.LatMin != nil && a.LatMax != nil && a.LonMin != nil && a.LonMax != nil: + latMin, latMax, lonMin, lonMax = *a.LatMin, *a.LatMax, *a.LonMin, *a.LonMax + default: + return math.MaxFloat64 + } + return (latMax - latMin) * (lonMax - lonMin) +} + // ListLimitsConfig defines maximum row limits for list endpoints to prevent DoS. type ListLimitsConfig struct { PacketsMax int `json:"packetsMax"` diff --git a/cmd/server/config_test.go b/cmd/server/config_test.go index b78968ab..393fabbd 100644 --- a/cmd/server/config_test.go +++ b/cmd/server/config_test.go @@ -515,3 +515,60 @@ func TestApplyListLimitsDefaults(t *testing.T) { } }) } + +func TestAreaForPoint(t *testing.T) { + f := func(v float64) *float64 { return &v } + + areas := map[string]AreaEntry{ + "DK": { + Label: "Danmark (alle)", + LatMin: f(54.5), LatMax: f(57.8), + LonMin: f(8.0), LonMax: f(15.25), + }, + "FYN": { + Label: "Fyn", + LatMin: f(54.9), LatMax: f(55.65), + LonMin: f(9.85), LonMax: f(11.0), + }, + "ODE": { + Label: "Odense by", + LatMin: f(55.32), LatMax: f(55.45), + LonMin: f(10.3), LonMax: f(10.5), + }, + } + + t.Run("picks the most specific nested area", func(t *testing.T) { + label, ok := AreaForPoint(55.4047, 10.381, areas) // central Odense + if !ok || label != "Odense by" { + t.Errorf("expected Odense by, got %q (ok=%v)", label, ok) + } + }) + + t.Run("falls back to a broader area when no narrower one matches", func(t *testing.T) { + label, ok := AreaForPoint(55.0, 10.6, areas) // Fyn but not Odense + if !ok || label != "Fyn" { + t.Errorf("expected Fyn, got %q (ok=%v)", label, ok) + } + }) + + t.Run("no match outside every area", func(t *testing.T) { + _, ok := AreaForPoint(60.0, 20.0, areas) + if ok { + t.Error("expected no match far outside Denmark") + } + }) + + t.Run("zero coordinates never match", func(t *testing.T) { + _, ok := AreaForPoint(0, 0, areas) + if ok { + t.Error("expected (0,0) to never match an area") + } + }) + + t.Run("empty areas map", func(t *testing.T) { + _, ok := AreaForPoint(55.4, 10.4, map[string]AreaEntry{}) + if ok { + t.Error("expected no match with no configured areas") + } + }) +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 9172363a..755a9a92 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -3776,6 +3776,14 @@ func (s *Server) handleWardrivingStats(w http.ResponseWriter, r *http.Request) { } } + if s.cfg != nil && len(s.cfg.Areas) > 0 { + for i := range resp.GPSShares { + if label, ok := AreaForPoint(resp.GPSShares[i].Lat, resp.GPSShares[i].Lon, s.cfg.Areas); ok { + resp.GPSShares[i].Area = &label + } + } + } + s.wardrivingStatsMu.Lock() if s.wardrivingStatsCache == nil { s.wardrivingStatsCache = make(map[string]*WardrivingStatsResponse) diff --git a/cmd/server/types.go b/cmd/server/types.go index 918bfc4e..9c14bd90 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -299,6 +299,9 @@ type WardrivingGPSShare struct { Lon float64 `json:"lon"` MessageCount int `json:"messageCount"` // how many times this sender shared a position in this window LastSeen string `json:"lastSeen"` + // Area is the most specific configured area containing (Lat, Lon), set + // by the handler from config.Areas — omitted when no area matches. + Area *string `json:"area,omitempty"` } type WardrivingStatsResponse struct { diff --git a/cmd/server/wardriving_stats_test.go b/cmd/server/wardriving_stats_test.go index 17ac787c..981098f0 100644 --- a/cmd/server/wardriving_stats_test.go +++ b/cmd/server/wardriving_stats_test.go @@ -449,6 +449,64 @@ func TestHandleWardrivingStats_GPSShares(t *testing.T) { } } +// TestHandleWardrivingStats_GPSShareArea confirms a shared position gets +// tagged with the most specific configured area (config.Areas), and is left +// unset when no area matches or none are configured. +func TestHandleWardrivingStats_GPSShareArea(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + f := func(v float64) *float64 { return &v } + srv.cfg.Areas = map[string]AreaEntry{ + "DK": {Label: "Danmark (alle)", LatMin: f(54.5), LatMax: f(57.8), LonMin: f(8.0), LonMax: f(15.25)}, + "ODE": {Label: "Odense by", LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)}, + } + + insertTx := func(hash, sender, mmPayload string) { + ts := time.Now().UTC().Add(-30 * time.Minute).Format(time.RFC3339) + text := sender + ": " + mmPayload + if _, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`, + "aa", hash, ts, `{"sender":"`+sender+`","text":"`+text+`"}`, + ); err != nil { + t.Fatalf("insert tx %s: %v", hash, err) + } + } + insertTx("in-ode", "InOdense", "MM:c3e_zJ1rUA:55.4047,10.3810") // inside Odense (and DK) + insertTx("outside", "Elsewhere", "MM:c3e_zJ1rUA:40.0000,-74.0000") // outside every configured area + + req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var resp WardrivingStatsResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + + byShare := map[string]WardrivingGPSShare{} + for _, s := range resp.GPSShares { + byShare[s.Sender] = s + } + inOdense, ok := byShare["InOdense"] + if !ok { + t.Fatal("InOdense missing from GPSShares") + } + if inOdense.Area == nil || *inOdense.Area != "Odense by" { + t.Errorf("InOdense.Area = %v, want \"Odense by\" (most specific match, not \"Danmark (alle)\")", inOdense.Area) + } + elsewhere, ok := byShare["Elsewhere"] + if !ok { + t.Fatal("Elsewhere missing from GPSShares") + } + if elsewhere.Area != nil { + t.Errorf("Elsewhere.Area = %v, want nil (outside every configured area)", *elsewhere.Area) + } +} + // TestHandleWardrivingStats_InvalidWindow mirrors the existing scope-stats // window validation. func TestHandleWardrivingStats_InvalidWindow(t *testing.T) { diff --git a/public/analytics.js b/public/analytics.js index cbf36502..bf361b6e 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5650,13 +5650,17 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf return '

No sender has shared an explicit position in this window.

'; } var rows = shares.map(function(s) { + var areaBadge = s.area + ? '' + esc(s.area) + '' + : '—'; return '' + esc(s.sender) + '' + '' + mapLinkHtml(s.lat, s.lon) + '' + + '' + areaBadge + '' + '' + s.messageCount.toLocaleString() + '' + '' + (typeof timeAgo === 'function' ? timeAgo(s.lastSeen) : s.lastSeen) + ''; }).join(''); return '' + - '' + + '' + '' + rows + '' + '
SenderPosition (most recent)Times SharedLast Seen
SenderPosition (most recent)AreaTimes SharedLast Seen
'; } diff --git a/test-analytics-wardriving-tab.js b/test-analytics-wardriving-tab.js index f8499820..c2556391 100644 --- a/test-analytics-wardriving-tab.js +++ b/test-analytics-wardriving-tab.js @@ -188,6 +188,23 @@ function makeApiStub(wardrivingResp, resolveHopsResp) { assert.ok(section.includes('href="#/map?lat=55.59743&lon=13.00128&zoom=15"'), 'the position should link to the live map centered on it'); }); + await testAsync('GPS Sharing shows the area badge when the API resolved one, and a dash otherwise', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse({ + gpsShares: [ + { sender: 'InOdense', lat: 55.4047, lon: 10.381, messageCount: 3, lastSeen: '2026-07-20T09:00:00Z', area: 'Odense by' }, + { sender: 'NoAreaMatch', lat: 40.0, lon: -74.0, messageCount: 1, lastSeen: '2026-07-20T09:00:00Z' }, + ], + }))); + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + const startIdx = el.innerHTML.indexOf('id="wardrivingGPSShares"'); + const section = el.innerHTML.slice(startIdx); + assert.ok(section.includes('Area'), 'GPS Sharing table should have an Area column'); + assert.ok(section.includes('>Odense by<'), 'a resolved area should render as a badge with its label'); + const noAreaRowIdx = section.indexOf('NoAreaMatch'); + assert.ok(noAreaRowIdx > -1, 'the unresolved-area sender should still be listed'); + }); + await testAsync('GPS Sharing shows a neutral message when nobody has shared a position', async () => { const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse({ gpsShares: [] }))); const el = fakeEl();