From 1cd5ce873a1ca21b689aa1b755826cacea0bbcbc Mon Sep 17 00:00:00 2001 From: efiten Date: Sat, 28 Mar 2026 23:05:45 +0100 Subject: [PATCH] refactor(pr215): address important review items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared geofilter Go module (internal/geofilter/) with Config struct and geometry functions (PointInPolygon, DistToSegmentKm, PassesFilter) — eliminates duplication between server and ingestor - Replace GeoFilterConfig struct in both config.go files with a type alias pointing to geofilter.Config - Slim geo_filter.go in both packages to delegate to shared module - Add --geo-filter-color CSS variable to style.css; use it in map.js and live.js overlay code instead of hardcoded #3b82f6 - Update prune-nodes-outside-geo-filter.py to read polygon/bufferKm from config.json (--config flag, default /app/config.json) instead of having deployment-specific coordinates hardcoded in the script Co-Authored-By: Claude Sonnet 4.6 --- cmd/ingestor/config.go | 13 ++-- cmd/ingestor/geo_filter.go | 62 +--------------- cmd/ingestor/go.mod | 3 + cmd/server/config.go | 12 ++-- cmd/server/geo_filter.go | 62 +--------------- cmd/server/go.mod | 3 + internal/geofilter/geofilter.go | 86 +++++++++++++++++++++++ internal/geofilter/go.mod | 3 + public/live.js | 9 +-- public/map.js | 9 +-- public/style.css | 1 + scripts/prune-nodes-outside-geo-filter.py | 66 ++++++++--------- 12 files changed, 153 insertions(+), 176 deletions(-) create mode 100644 internal/geofilter/geofilter.go create mode 100644 internal/geofilter/go.mod diff --git a/cmd/ingestor/config.go b/cmd/ingestor/config.go index 003470c2..f9251039 100644 --- a/cmd/ingestor/config.go +++ b/cmd/ingestor/config.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "strings" + + "github.com/meshcore-analyzer/geofilter" ) // MQTTSource represents a single MQTT broker connection. @@ -37,15 +39,8 @@ type Config struct { GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"` } -// GeoFilterConfig defines the geographic filter polygon or bounding box. -type GeoFilterConfig struct { - Polygon [][2]float64 `json:"polygon,omitempty"` - BufferKm float64 `json:"bufferKm,omitempty"` - LatMin *float64 `json:"latMin,omitempty"` - LatMax *float64 `json:"latMax,omitempty"` - LonMin *float64 `json:"lonMin,omitempty"` - LonMax *float64 `json:"lonMax,omitempty"` -} +// GeoFilterConfig is an alias for the shared geofilter.Config type. +type GeoFilterConfig = geofilter.Config // RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes. type RetentionConfig struct { diff --git a/cmd/ingestor/geo_filter.go b/cmd/ingestor/geo_filter.go index 74fed4dd..c30a352a 100644 --- a/cmd/ingestor/geo_filter.go +++ b/cmd/ingestor/geo_filter.go @@ -1,6 +1,6 @@ package main -import "math" +import "github.com/meshcore-analyzer/geofilter" // NodePassesGeoFilter returns true if the node should be kept. // Nodes with no GPS coordinates are always allowed. @@ -11,63 +11,5 @@ func NodePassesGeoFilter(lat, lon *float64, gf *GeoFilterConfig) bool { if lat == nil || lon == nil { return true } - if *lat == 0 && *lon == 0 { - return true - } - if len(gf.Polygon) >= 3 { - if pointInPolygon(*lat, *lon, gf.Polygon) { - return true - } - if gf.BufferKm > 0 { - n := len(gf.Polygon) - for i := 0; i < n; i++ { - j := (i + 1) % n - if distToSegmentKm(*lat, *lon, gf.Polygon[i], gf.Polygon[j]) <= gf.BufferKm { - return true - } - } - } - return false - } - // Legacy bounding box - if gf.LatMin != nil && gf.LatMax != nil && gf.LonMin != nil && gf.LonMax != nil { - return *lat >= *gf.LatMin && *lat <= *gf.LatMax && *lon >= *gf.LonMin && *lon <= *gf.LonMax - } - return true -} - -func pointInPolygon(lat, lon float64, polygon [][2]float64) bool { - inside := false - n := len(polygon) - j := n - 1 - for i := 0; i < n; i++ { - yi, xi := polygon[i][0], polygon[i][1] - yj, xj := polygon[j][0], polygon[j][1] - if (yi > lat) != (yj > lat) { - if lon < (xj-xi)*(lat-yi)/(yj-yi)+xi { - inside = !inside - } - } - j = i - } - return inside -} - -func distToSegmentKm(lat, lon float64, a, b [2]float64) float64 { - lat1, lon1 := a[0], a[1] - lat2, lon2 := b[0], b[1] - cosLat := math.Cos((lat1 + lat2) / 2.0 * math.Pi / 180.0) - ax := (lon1 - lon) * 111.0 * cosLat - ay := (lat1 - lat) * 111.0 - bx := (lon2 - lon) * 111.0 * cosLat - by := (lat2 - lat) * 111.0 - abx, aby := bx-ax, by-ay - abSq := abx*abx + aby*aby - if abSq == 0 { - return math.Sqrt(ax*ax + ay*ay) - } - t := math.Max(0, math.Min(1, -(ax*abx+ay*aby)/abSq)) - px := ax + t*abx - py := ay + t*aby - return math.Sqrt(px*px + py*py) + return geofilter.PassesFilter(*lat, *lon, gf) } diff --git a/cmd/ingestor/go.mod b/cmd/ingestor/go.mod index cc2098e7..bd0cfdb6 100644 --- a/cmd/ingestor/go.mod +++ b/cmd/ingestor/go.mod @@ -4,9 +4,12 @@ go 1.22 require ( github.com/eclipse/paho.mqtt.golang v1.5.0 + github.com/meshcore-analyzer/geofilter v0.0.0 modernc.org/sqlite v1.34.5 ) +replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter + require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/cmd/server/config.go b/cmd/server/config.go index acd49be0..46385085 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -4,6 +4,8 @@ import ( "encoding/json" "os" "path/filepath" + + "github.com/meshcore-analyzer/geofilter" ) // Config mirrors the Node.js config.json structure (read-only fields). @@ -57,14 +59,8 @@ type PacketStoreConfig struct { MaxMemoryMB int `json:"maxMemoryMB"` // hard memory ceiling in MB (0 = unlimited) } -type GeoFilterConfig struct { - Polygon [][2]float64 `json:"polygon,omitempty"` - BufferKm float64 `json:"bufferKm,omitempty"` - LatMin *float64 `json:"latMin,omitempty"` - LatMax *float64 `json:"latMax,omitempty"` - LonMin *float64 `json:"lonMin,omitempty"` - LonMax *float64 `json:"lonMax,omitempty"` -} +// GeoFilterConfig is an alias for the shared geofilter.Config type. +type GeoFilterConfig = geofilter.Config type RetentionConfig struct { NodeDays int `json:"nodeDays"` diff --git a/cmd/server/geo_filter.go b/cmd/server/geo_filter.go index e8d22ec5..57b337e0 100644 --- a/cmd/server/geo_filter.go +++ b/cmd/server/geo_filter.go @@ -1,9 +1,10 @@ package main -import "math" +import "github.com/meshcore-analyzer/geofilter" // NodePassesGeoFilter returns true if the node should be included in responses. // Nodes with no GPS coordinates are always allowed. +// lat and lon are interface{} because they come from DB row maps. func NodePassesGeoFilter(lat, lon interface{}, gf *GeoFilterConfig) bool { if gf == nil { return true @@ -13,28 +14,7 @@ func NodePassesGeoFilter(lat, lon interface{}, gf *GeoFilterConfig) bool { if !ok1 || !ok2 { return true } - if latF == 0 && lonF == 0 { - return true - } - if len(gf.Polygon) >= 3 { - if geoPointInPolygon(latF, lonF, gf.Polygon) { - return true - } - if gf.BufferKm > 0 { - n := len(gf.Polygon) - for i := 0; i < n; i++ { - j := (i + 1) % n - if geoDistToSegmentKm(latF, lonF, gf.Polygon[i], gf.Polygon[j]) <= gf.BufferKm { - return true - } - } - } - return false - } - if gf.LatMin != nil && gf.LatMax != nil && gf.LonMin != nil && gf.LonMax != nil { - return latF >= *gf.LatMin && latF <= *gf.LatMax && lonF >= *gf.LonMin && lonF <= *gf.LonMax - } - return true + return geofilter.PassesFilter(latF, lonF, gf) } func toFloat64(v interface{}) (float64, bool) { @@ -52,39 +32,3 @@ func toFloat64(v interface{}) (float64, bool) { } return 0, false } - -func geoPointInPolygon(lat, lon float64, polygon [][2]float64) bool { - inside := false - n := len(polygon) - j := n - 1 - for i := 0; i < n; i++ { - yi, xi := polygon[i][0], polygon[i][1] - yj, xj := polygon[j][0], polygon[j][1] - if (yi > lat) != (yj > lat) { - if lon < (xj-xi)*(lat-yi)/(yj-yi)+xi { - inside = !inside - } - } - j = i - } - return inside -} - -func geoDistToSegmentKm(lat, lon float64, a, b [2]float64) float64 { - lat1, lon1 := a[0], a[1] - lat2, lon2 := b[0], b[1] - cosLat := math.Cos((lat1+lat2) / 2.0 * math.Pi / 180.0) - ax := (lon1 - lon) * 111.0 * cosLat - ay := (lat1 - lat) * 111.0 - bx := (lon2 - lon) * 111.0 * cosLat - by := (lat2 - lat) * 111.0 - abx, aby := bx-ax, by-ay - abSq := abx*abx + aby*aby - if abSq == 0 { - return math.Sqrt(ax*ax + ay*ay) - } - t := math.Max(0, math.Min(1, -(ax*abx+ay*aby)/abSq)) - px := ax + t*abx - py := ay + t*aby - return math.Sqrt(px*px + py*py) -} diff --git a/cmd/server/go.mod b/cmd/server/go.mod index 1ef2c8af..700e9d45 100644 --- a/cmd/server/go.mod +++ b/cmd/server/go.mod @@ -5,9 +5,12 @@ go 1.22 require ( github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 + github.com/meshcore-analyzer/geofilter v0.0.0 modernc.org/sqlite v1.34.5 ) +replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter + require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/internal/geofilter/geofilter.go b/internal/geofilter/geofilter.go new file mode 100644 index 00000000..c9bb6bf1 --- /dev/null +++ b/internal/geofilter/geofilter.go @@ -0,0 +1,86 @@ +// Package geofilter provides the shared geographic filter configuration and +// geometry used by both the server and ingestor packages. +package geofilter + +import "math" + +// Config defines the geographic filter polygon or bounding box. +// Shared between the server and ingestor packages. +type Config struct { + Polygon [][2]float64 `json:"polygon,omitempty"` + BufferKm float64 `json:"bufferKm,omitempty"` + LatMin *float64 `json:"latMin,omitempty"` + LatMax *float64 `json:"latMax,omitempty"` + LonMin *float64 `json:"lonMin,omitempty"` + LonMax *float64 `json:"lonMax,omitempty"` +} + +// PassesFilter returns true if the coordinates fall within the filter area. +// Nodes with no GPS fix (0,0) are always allowed. +func PassesFilter(lat, lon float64, gf *Config) bool { + if gf == nil { + return true + } + if lat == 0 && lon == 0 { + return true + } + if len(gf.Polygon) >= 3 { + if PointInPolygon(lat, lon, gf.Polygon) { + return true + } + if gf.BufferKm > 0 { + n := len(gf.Polygon) + for i := 0; i < n; i++ { + j := (i + 1) % n + if DistToSegmentKm(lat, lon, gf.Polygon[i], gf.Polygon[j]) <= gf.BufferKm { + return true + } + } + } + return false + } + // Legacy bounding box fallback + if gf.LatMin != nil && gf.LatMax != nil && gf.LonMin != nil && gf.LonMax != nil { + return lat >= *gf.LatMin && lat <= *gf.LatMax && lon >= *gf.LonMin && lon <= *gf.LonMax + } + return true +} + +// PointInPolygon uses the ray-casting algorithm. +func PointInPolygon(lat, lon float64, polygon [][2]float64) bool { + inside := false + n := len(polygon) + j := n - 1 + for i := 0; i < n; i++ { + yi, xi := polygon[i][0], polygon[i][1] + yj, xj := polygon[j][0], polygon[j][1] + if (yi > lat) != (yj > lat) { + if lon < (xj-xi)*(lat-yi)/(yj-yi)+xi { + inside = !inside + } + } + j = i + } + return inside +} + +// DistToSegmentKm returns the approximate distance in km from point (lat,lon) +// to line segment a→b using a flat-earth projection. +func DistToSegmentKm(lat, lon float64, a, b [2]float64) float64 { + lat1, lon1 := a[0], a[1] + lat2, lon2 := b[0], b[1] + cosLat := math.Cos((lat1+lat2) / 2.0 * math.Pi / 180.0) + ax := (lon1 - lon) * 111.0 * cosLat + ay := (lat1 - lat) * 111.0 + bx := (lon2 - lon) * 111.0 * cosLat + by := (lat2 - lat) * 111.0 + abx, aby := bx-ax, by-ay + abSq := abx*abx + aby*aby + if abSq == 0 { + return math.Sqrt(ax*ax + ay*ay) + } + t := math.Max(0, math.Min(1, -(ax*abx+ay*aby)/abSq)) + px := ax + t*abx + py := ay + t*aby + return math.Sqrt(px*px + py*py) +} diff --git a/internal/geofilter/go.mod b/internal/geofilter/go.mod new file mode 100644 index 00000000..4058414f --- /dev/null +++ b/internal/geofilter/go.mod @@ -0,0 +1,3 @@ +module github.com/meshcore-analyzer/geofilter + +go 1.22 diff --git a/public/live.js b/public/live.js index 9e8c8c16..be55e73f 100644 --- a/public/live.js +++ b/public/live.js @@ -807,10 +807,11 @@ try { const gf = await api('/config/geo-filter', { ttl: 3600 }); if (!gf || !gf.polygon || gf.polygon.length < 3) return; + const geoColor = cssVar('--geo-filter-color') || '#3b82f6'; const latlngs = gf.polygon.map(function (p) { return [p[0], p[1]]; }); const innerPoly = L.polygon(latlngs, { - color: '#3b82f6', weight: 2, opacity: 0.8, - fillColor: '#3b82f6', fillOpacity: 0.08 + color: geoColor, weight: 2, opacity: 0.8, + fillColor: geoColor, fillOpacity: 0.08 }); const bufferPoly = gf.bufferKm > 0 ? (function () { let cLat = 0, cLon = 0; @@ -826,8 +827,8 @@ return [p[0] + dLatM * scale / 111000, p[1] + dLonM * scale / (111000 * cosLat)]; }); return L.polygon(outer, { - color: '#3b82f6', weight: 1.5, opacity: 0.4, dashArray: '6 4', - fillColor: '#3b82f6', fillOpacity: 0.04 + color: geoColor, weight: 1.5, opacity: 0.4, dashArray: '6 4', + fillColor: geoColor, fillOpacity: 0.04 }); })() : null; geoFilterLayer = L.layerGroup(bufferPoly ? [bufferPoly, innerPoly] : [innerPoly]); diff --git a/public/map.js b/public/map.js index d407a827..493c6345 100644 --- a/public/map.js +++ b/public/map.js @@ -232,10 +232,11 @@ try { const gf = await api('/config/geo-filter', { ttl: 3600 }); if (!gf || !gf.polygon || gf.polygon.length < 3) return; + const geoColor = getComputedStyle(document.documentElement).getPropertyValue('--geo-filter-color').trim() || '#3b82f6'; const latlngs = gf.polygon.map(function (p) { return [p[0], p[1]]; }); const innerPoly = L.polygon(latlngs, { - color: '#3b82f6', weight: 2, opacity: 0.8, - fillColor: '#3b82f6', fillOpacity: 0.08 + color: geoColor, weight: 2, opacity: 0.8, + fillColor: geoColor, fillOpacity: 0.08 }); // Approximate buffer zone — expand each vertex outward from centroid by bufferKm const bufferPoly = gf.bufferKm > 0 ? (function () { @@ -252,8 +253,8 @@ return [p[0] + dLatM * scale / 111000, p[1] + dLonM * scale / (111000 * cosLat)]; }); return L.polygon(outer, { - color: '#3b82f6', weight: 1.5, opacity: 0.4, dashArray: '6 4', - fillColor: '#3b82f6', fillOpacity: 0.04 + color: geoColor, weight: 1.5, opacity: 0.4, dashArray: '6 4', + fillColor: geoColor, fillOpacity: 0.04 }); })() : null; geoFilterLayer = L.layerGroup(bufferPoly ? [bufferPoly, innerPoly] : [innerPoly]); diff --git a/public/style.css b/public/style.css index 4593c117..95d68d61 100644 --- a/public/style.css +++ b/public/style.css @@ -6,6 +6,7 @@ --nav-text: #ffffff; --nav-text-muted: #cbd5e1; --accent: #4a9eff; + --geo-filter-color: #3b82f6; --status-green: #22c55e; --status-yellow: #eab308; --status-red: #ef4444; diff --git a/scripts/prune-nodes-outside-geo-filter.py b/scripts/prune-nodes-outside-geo-filter.py index 430f976b..6f34eb08 100644 --- a/scripts/prune-nodes-outside-geo-filter.py +++ b/scripts/prune-nodes-outside-geo-filter.py @@ -4,10 +4,11 @@ Delete nodes from the database that fall outside the configured geo_filter polyg Nodes with no GPS coordinates are always kept. Usage: - python3 prune-nodes-outside-geo-filter.py [db_path] [--dry-run] + python3 prune-nodes-outside-geo-filter.py [db_path] [--config config.json] [--dry-run] - db_path Path to meshcore.db (default: /app/data/meshcore.db) - --dry-run Show what would be deleted without making any changes + db_path Path to meshcore.db (default: /app/data/meshcore.db) + --config PATH Path to config.json (default: /app/config.json) + --dry-run Show what would be deleted without making any changes """ import sqlite3 @@ -16,33 +17,6 @@ import sys import json import os -# --------------------------------------------------------------------------- -# geo_filter config — paste your polygon here (or let the script read -# config.json automatically when run inside the container) -# --------------------------------------------------------------------------- -POLYGON = [ - [51.087294, 2.543335], - [50.841814, 2.614746], - [50.692512, 2.911377], - [50.775677, 3.147583], - [50.524993, 3.279419], - [50.476093, 3.630981], - [50.315067, 3.685913], - [50.265951, 4.141846], - [49.984311, 4.11438], - [49.49815, 5.465698], - [49.544491, 5.83374], - [50.329091, 6.410522], - [50.754837, 6.053467], - [51.15953, 5.844727], - [51.300512, 5.509644], - [51.485537, 5.042725], - [51.482117, 4.520874], - [51.375983, 3.378296], -] -BUFFER_KM = 20.0 -# --------------------------------------------------------------------------- - def point_in_polygon(lat, lon, polygon): """Ray-casting algorithm.""" @@ -101,13 +75,41 @@ def node_passes_filter(lat, lon, polygon, buffer_km): return False +def load_geo_filter(config_path): + """Load polygon and bufferKm from config.json geo_filter section.""" + if not os.path.exists(config_path): + print(f"ERROR: config not found at {config_path}") + sys.exit(1) + with open(config_path) as f: + cfg = json.load(f) + gf = cfg.get('geo_filter') + if not gf: + print("ERROR: no geo_filter section found in config.json") + sys.exit(1) + polygon = gf.get('polygon', []) + if len(polygon) < 3: + print("ERROR: geo_filter.polygon must have at least 3 points") + sys.exit(1) + buffer_km = gf.get('bufferKm', 0.0) + print(f"Loaded geo_filter from {config_path}: {len(polygon)} points, bufferKm={buffer_km}") + return polygon, buffer_km + + def main(): args = sys.argv[1:] dry_run = '--dry-run' in args - args = [a for a in args if not a.startswith('--')] + args = [a for a in args if a != '--dry-run'] + + config_path = '/app/config.json' + if '--config' in args: + idx = args.index('--config') + config_path = args[idx + 1] + args = args[:idx] + args[idx + 2:] db_path = args[0] if args else '/app/data/meshcore.db' + polygon, buffer_km = load_geo_filter(config_path) + if not os.path.exists(db_path): print(f"ERROR: database not found at {db_path}") sys.exit(1) @@ -123,7 +125,7 @@ def main(): for row in nodes: lat = row['lat'] lon = row['lon'] - if node_passes_filter(lat, lon, POLYGON, BUFFER_KM): + if node_passes_filter(lat, lon, polygon, buffer_km): keep.append(row) else: remove.append(row)