mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 00:28:00 +00:00
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:
co-authored by
Claude Sonnet 5
parent
c1129093f5
commit
303b5d452d
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>';
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user