mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-14 21:55:08 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/meshcore-analyzer/geofilter
|
||||
|
||||
go 1.22
|
||||
+5
-4
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user