refactor(pr215): address important review items

- 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 <noreply@anthropic.com>
This commit is contained in:
efiten
2026-03-28 23:05:45 +01:00
committed by KpaBap
parent b40f5fbb75
commit 1cd5ce873a
12 changed files with 153 additions and 176 deletions
+4 -9
View File
@@ -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 {
+2 -60
View File
@@ -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)
}
+3
View File
@@ -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
+4 -8
View File
@@ -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"`
+3 -59
View File
@@ -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)
}
+3
View File
@@ -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
+86
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/meshcore-analyzer/geofilter
go 1.22
+5 -4
View File
@@ -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]);
+5 -4
View File
@@ -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]);
+1
View File
@@ -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;
+34 -32
View File
@@ -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)