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 <noreply@anthropic.com>
This commit is contained in:
dborup
2026-07-21 12:33:20 +02:00
co-authored by Claude Sonnet 5
parent c1129093f5
commit 303b5d452d
7 changed files with 193 additions and 1 deletions
+45
View File
@@ -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"`
+57
View File
@@ -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")
}
})
}
+8
View File
@@ -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)
+3
View File
@@ -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 {
+58
View File
@@ -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) {
+5 -1
View File
@@ -5650,13 +5650,17 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
return '<p class="text-muted" style="font-size:0.85em">No sender has shared an explicit position in this window.</p>';
}
var rows = shares.map(function(s) {
var areaBadge = s.area
? '<span class="badge" style="background:var(--border);color:var(--text)" title="Most specific configured area containing this position">' + esc(s.area) + '</span>'
: '<span class="text-muted" style="font-size:0.85em">—</span>';
return '<tr><td>' + esc(s.sender) + '</td>' +
'<td>' + mapLinkHtml(s.lat, s.lon) + '</td>' +
'<td>' + areaBadge + '</td>' +
'<td>' + s.messageCount.toLocaleString() + '</td>' +
'<td>' + (typeof timeAgo === 'function' ? timeAgo(s.lastSeen) : s.lastSeen) + '</td></tr>';
}).join('');
return '<table class="data-table analytics-table">' +
'<thead><tr><th>Sender</th><th>Position (most recent)</th><th>Times Shared</th><th>Last Seen</th></tr></thead>' +
'<thead><tr><th>Sender</th><th>Position (most recent)</th><th>Area</th><th>Times Shared</th><th>Last Seen</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
}
+17
View File
@@ -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('<th>Area</th>'), '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();