From 1881c92d6ea4b84e8c1da961c1ea13c13dd5397e Mon Sep 17 00:00:00 2001 From: efiten Date: Tue, 14 Apr 2026 10:08:08 +0200 Subject: [PATCH 1/3] feat: geofilter customizer tab + PUT /api/config/geo-filter (#669 M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add PUT /api/config/geo-filter (requires X-API-Key) β€” saves geo_filter back to config.json atomically and updates in-memory config immediately, no restart needed - Add SaveGeoFilter() to config.go: reads config as raw map (preserving _comment fields), updates geo_filter key, writes back via temp+rename - Add writeEnabled field to GET /api/config/geo-filter response so the frontend can gate editing controls on server write capability - Add Server.configDir field; wired from -config-dir flag in main.go - Tests: TestPutConfigGeoFilter (4 cases) + TestSaveGeoFilter (3 cases) Frontend: - Add GeoFilter tab (πŸ—ΊοΈ) to the customizer between Display and Export - Tab shows current polygon on a Leaflet map (read-only for all users) - Editing controls (undo, clear, buffer km, API key input, save/remove) are only revealed when the server reports writeEnabled=true β€” i.e. the deployment has a write-capable apiKey configured. Public instances see a read-only polygon view. - Save calls PUT /api/config/geo-filter; Remove clears the filter - Map is destroyed on tab switch and panel close to avoid Leaflet leaks Docs: - Add docs/user-guide/geofilter.md (full guide: config, customizer, builder, prune script, API) - Update configuration.md and customization.md with geo_filter section - Update config.example.json _comment to mention the Customizer tab Co-Authored-By: Claude Sonnet 4.6 --- cmd/server/config.go | 57 ++++++++++ cmd/server/main.go | 1 + cmd/server/routes.go | 45 +++++++- cmd/server/routes_test.go | 189 +++++++++++++++++++++++++++++++ config.example.json | 2 +- docs/user-guide/configuration.md | 4 +- docs/user-guide/customization.md | 8 +- docs/user-guide/geofilter.md | 74 +++++++----- public/customize-v2.js | 183 +++++++++++++++++++++++++++++- 9 files changed, 525 insertions(+), 38 deletions(-) diff --git a/cmd/server/config.go b/cmd/server/config.go index f21ef207..29c94b49 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "log" "os" "path/filepath" @@ -404,3 +405,59 @@ func (c *Config) IsBlacklisted(pubkey string) bool { } return c.blacklistSet()[strings.ToLower(strings.TrimSpace(pubkey))] } + +// SaveGeoFilter writes the geo_filter section back to config.json on disk. +// Pass gf=nil to remove the filter. The rest of config.json is preserved as-is. +func SaveGeoFilter(configDir string, gf *GeoFilterConfig) error { + var configPath string + for _, p := range []string{ + filepath.Join(configDir, "config.json"), + filepath.Join(configDir, "data", "config.json"), + } { + if _, err := os.Stat(p); err == nil { + configPath = p + break + } + } + if configPath == "" { + return fmt.Errorf("config.json not found in %s", configDir) + } + + data, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("read config: %w", err) + } + + // Parse as a raw map so non-struct fields (_comment, etc.) are preserved. + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("parse config: %w", err) + } + + if gf == nil || len(gf.Polygon) == 0 { + delete(raw, "geo_filter") + } else { + // Round-trip through JSON to get a plain interface{} value. + b, _ := json.Marshal(gf) + var v interface{} + _ = json.Unmarshal(b, &v) + raw["geo_filter"] = v + } + + out, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + out = append(out, '\n') + + // Atomic write: temp file + rename. + tmp := configPath + ".tmp" + if err := os.WriteFile(tmp, out, 0644); err != nil { + return fmt.Errorf("write config: %w", err) + } + if err := os.Rename(tmp, configPath); err != nil { + os.Remove(tmp) + return fmt.Errorf("rename config: %w", err) + } + return nil +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 31fbcd4f..855212fa 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -238,6 +238,7 @@ func main() { // HTTP server srv := NewServer(database, cfg, hub) + srv.configDir = configDir srv.store = store router := mux.NewRouter() srv.RegisterRoutes(router) diff --git a/cmd/server/routes.go b/cmd/server/routes.go index aa7c2689..c6dae54e 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -25,6 +25,7 @@ type Server struct { cfg *Config hub *Hub store *PacketStore // in-memory packet store (nil = fallback to DB) + configDir string // directory containing config.json (for write-back) startedAt time.Time perfStats *PerfStats version string @@ -117,6 +118,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/config/theme", s.handleConfigTheme).Methods("GET") r.HandleFunc("/api/config/map", s.handleConfigMap).Methods("GET") r.HandleFunc("/api/config/geo-filter", s.handleConfigGeoFilter).Methods("GET") + r.Handle("/api/config/geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePutConfigGeoFilter))).Methods("PUT") // System endpoints r.HandleFunc("/api/health", s.handleHealth).Methods("GET") @@ -431,11 +433,50 @@ func (s *Server) handleConfigMap(w http.ResponseWriter, r *http.Request) { func (s *Server) handleConfigGeoFilter(w http.ResponseWriter, r *http.Request) { gf := s.cfg.GeoFilter + writeEnabled := s.cfg != nil && s.cfg.APIKey != "" && !IsWeakAPIKey(s.cfg.APIKey) if gf == nil || len(gf.Polygon) == 0 { - writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0}) + writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0, "writeEnabled": writeEnabled}) + return + } + writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm, "writeEnabled": writeEnabled}) +} + +func (s *Server) handlePutConfigGeoFilter(w http.ResponseWriter, r *http.Request) { + var body struct { + Polygon [][2]float64 `json:"polygon"` + BufferKm float64 `json:"bufferKm"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + // Allow clearing (empty/null polygon) or a valid polygon with β‰₯ 3 points. + if len(body.Polygon) > 0 && len(body.Polygon) < 3 { + writeError(w, http.StatusBadRequest, "polygon must have at least 3 points") return } - writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm}) + + var gf *GeoFilterConfig + if len(body.Polygon) >= 3 { + gf = &GeoFilterConfig{Polygon: body.Polygon, BufferKm: body.BufferKm} + } + + if s.configDir != "" { + if err := SaveGeoFilter(s.configDir, gf); err != nil { + log.Printf("[geofilter] save failed: %v", err) + writeError(w, http.StatusInternalServerError, "failed to save config") + return + } + } + + s.cfg.GeoFilter = gf + + if gf != nil { + writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm}) + } else { + writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0}) + } } // --- System Handlers --- diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 4ac15f54..5c2c15cb 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "strconv" "strings" "testing" @@ -2839,6 +2841,34 @@ func TestConfigGeoFilterEndpoint(t *testing.T) { if body["bufferKm"] == nil { t.Error("expected bufferKm in response") } + if _, ok := body["writeEnabled"]; !ok { + t.Error("expected writeEnabled field in response") + } + // No apiKey configured β†’ writeEnabled should be false + if body["writeEnabled"] != false { + t.Errorf("expected writeEnabled=false when no apiKey, got %v", body["writeEnabled"]) + } + }) + + t.Run("writeEnabled true when strong apiKey configured", func(t *testing.T) { + db := setupTestDB(t) + cfg := &Config{Port: 3000, APIKey: "a-strong-api-key-1234"} + hub := NewHub() + srv := NewServer(db, cfg, hub) + srv.store = NewPacketStore(db, nil) + srv.store.Load() + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/config/geo-filter", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + if body["writeEnabled"] != true { + t.Errorf("expected writeEnabled=true when strong apiKey configured, got %v", body["writeEnabled"]) + } }) } @@ -3972,3 +4002,162 @@ func TestPacketDetailPrefersStoreOverDB(t *testing.T) { t.Errorf("expected observation_count=2 (from store), got %v", body["observation_count"]) } } + +// --- geo-filter write-back tests --- + +func setupGeoFilterServer(t *testing.T, apiKey string) (*Server, *mux.Router, string) { + t.Helper() + dir := t.TempDir() + cfgJSON := `{"port":3000,"apiKey":"` + apiKey + `"}` + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(cfgJSON), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000, APIKey: apiKey} + hub := NewHub() + srv := NewServer(db, cfg, hub) + srv.configDir = dir + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load: %v", err) + } + srv.store = store + router := mux.NewRouter() + srv.RegisterRoutes(router) + return srv, router, dir +} + +func TestPutConfigGeoFilter(t *testing.T) { + const apiKey = "a-strong-api-key-for-testing" + + t.Run("saves valid polygon and updates in-memory config", func(t *testing.T) { + srv, router, dir := setupGeoFilterServer(t, apiKey) + + body := `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,5.0],[50.5,4.0]],"bufferKm":15}` + req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body)) + req.Header.Set("X-API-Key", apiKey) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // In-memory config updated + if srv.cfg.GeoFilter == nil { + t.Fatal("expected in-memory GeoFilter to be set") + } + if len(srv.cfg.GeoFilter.Polygon) != 4 { + t.Errorf("expected 4 polygon points, got %d", len(srv.cfg.GeoFilter.Polygon)) + } + if srv.cfg.GeoFilter.BufferKm != 15 { + t.Errorf("expected bufferKm=15, got %v", srv.cfg.GeoFilter.BufferKm) + } + + // config.json updated on disk + data, _ := os.ReadFile(filepath.Join(dir, "config.json")) + if !bytes.Contains(data, []byte("geo_filter")) { + t.Error("expected geo_filter key in saved config.json") + } + }) + + t.Run("clears filter when polygon is empty", func(t *testing.T) { + srv, router, dir := setupGeoFilterServer(t, apiKey) + // Pre-set a filter so we can clear it + srv.cfg.GeoFilter = &GeoFilterConfig{Polygon: [][2]float64{{51.0, 4.0}, {51.0, 5.0}, {50.5, 4.0}}, BufferKm: 10} + + req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(`{"polygon":null}`)) + req.Header.Set("X-API-Key", apiKey) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + if srv.cfg.GeoFilter != nil { + t.Error("expected in-memory GeoFilter to be cleared") + } + data, _ := os.ReadFile(filepath.Join(dir, "config.json")) + if bytes.Contains(data, []byte("geo_filter")) { + t.Error("expected geo_filter to be removed from config.json") + } + }) + + t.Run("rejects polygon with fewer than 3 points", func(t *testing.T) { + _, router, _ := setupGeoFilterServer(t, apiKey) + + body := `{"polygon":[[51.0,4.0],[51.0,5.0]],"bufferKm":0}` + req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body)) + req.Header.Set("X-API-Key", apiKey) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } + }) + + t.Run("rejects missing API key", func(t *testing.T) { + _, router, _ := setupGeoFilterServer(t, apiKey) + + body := `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,4.0]],"bufferKm":0}` + req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } + }) +} + +func TestSaveGeoFilter(t *testing.T) { + t.Run("saves and reads back", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"port":3000}`), 0644); err != nil { + t.Fatal(err) + } + gf := &GeoFilterConfig{ + Polygon: [][2]float64{{51.0, 4.0}, {51.0, 5.0}, {50.5, 4.0}}, + BufferKm: 20, + } + if err := SaveGeoFilter(dir, gf); err != nil { + t.Fatalf("SaveGeoFilter: %v", err) + } + data, _ := os.ReadFile(filepath.Join(dir, "config.json")) + if !bytes.Contains(data, []byte("geo_filter")) { + t.Error("expected geo_filter in saved config") + } + if !bytes.Contains(data, []byte(`"bufferKm"`)) { + t.Error("expected bufferKm in saved config") + } + }) + + t.Run("removes geo_filter key when gf is nil", func(t *testing.T) { + dir := t.TempDir() + initial := `{"port":3000,"geo_filter":{"polygon":[[1,2],[3,4],[5,6]],"bufferKm":5}}` + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(initial), 0644); err != nil { + t.Fatal(err) + } + if err := SaveGeoFilter(dir, nil); err != nil { + t.Fatalf("SaveGeoFilter: %v", err) + } + data, _ := os.ReadFile(filepath.Join(dir, "config.json")) + if bytes.Contains(data, []byte("geo_filter")) { + t.Error("expected geo_filter to be removed") + } + }) + + t.Run("returns error when config.json not found", func(t *testing.T) { + dir := t.TempDir() + err := SaveGeoFilter(dir, nil) + if err == nil { + t.Error("expected error when config.json not found") + } + }) +} diff --git a/config.example.json b/config.example.json index 7e8e80a3..4a32a897 100644 --- a/config.example.json +++ b/config.example.json @@ -169,7 +169,7 @@ [37.20, -122.52] ], "bufferKm": 20, - "_comment": "Optional. Restricts ingestion and API responses to nodes within the polygon + bufferKm. Polygon is an array of [lat, lon] pairs (minimum 3). Use tools/geofilter-builder.html to draw a polygon visually. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through." + "_comment": "Optional. Restricts ingestion and API responses to nodes within the polygon + bufferKm. Polygon is an array of [lat, lon] pairs (minimum 3). Use the GeoFilter tab in the Customizer (requires apiKey) or tools/geofilter-builder.html to draw a polygon visually. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through." }, "regions": { "SJC": "San Jose, US", diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index 7ff59d94..7435fd3f 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -206,7 +206,9 @@ Provide cert and key paths to enable HTTPS. Restricts ingestion and API responses to nodes within the polygon plus a buffer margin. Remove the block to disable filtering. Nodes with no GPS fix always pass through. -See [Geographic Filtering](geofilter.md) for the full guide including the visual polygon builder and the prune script for cleaning up historical data. +Can also be configured live via the **πŸ—ΊοΈ GeoFilter** tab in the Customizer (requires `apiKey`). + +See [Geographic Filtering](geofilter.md) for the full guide. ## Home page diff --git a/docs/user-guide/customization.md b/docs/user-guide/customization.md index 888761e2..c4d982de 100644 --- a/docs/user-guide/customization.md +++ b/docs/user-guide/customization.md @@ -66,11 +66,13 @@ Click **Import JSON** and paste a previously exported theme. The customizer load Click **Reset to Defaults** to restore all settings to the built-in defaults. -## GeoFilter Builder +## GeoFilter (admin only) -The Export tab includes a **GeoFilter Builder β†’** link. Click it to open a Leaflet map where you can draw a polygon boundary for your deployment area. The tool generates a `geo_filter` block you can paste directly into `config.json`. +The **πŸ—ΊοΈ GeoFilter** tab lets operators configure geographic filtering directly from the customizer. It shows the active polygon on a Leaflet map and β€” on servers with a write-capable `apiKey` β€” allows editing the polygon and saving back to `config.json` without a restart. -See [Geographic Filtering](geofilter.md) for full details on what geo filtering does and how to configure it. +The editing controls are only revealed after the server confirms write access. On public deployments without an `apiKey`, the tab is read-only. + +See [Geographic Filtering](geofilter.md) for the full guide, including the API, the prune script, and the standalone GeoFilter Builder. ## How it works diff --git a/docs/user-guide/geofilter.md b/docs/user-guide/geofilter.md index 27d20b41..c61852d6 100644 --- a/docs/user-guide/geofilter.md +++ b/docs/user-guide/geofilter.md @@ -30,9 +30,9 @@ Add a `geo_filter` block to `config.json`: | Field | Type | Description | |-------|------|-------------| | `polygon` | `[[lat, lon], ...]` | Array of at least 3 coordinate pairs defining the boundary | -| `bufferKm` | number | Extra distance (km) around the polygon edge that is also accepted. `0` = exact boundary | +| `bufferKm` | number | Extra distance (km) outside the polygon edge that is also accepted. `0` = exact boundary | -Both the server and the ingestor read `geo_filter` from `config.json`. Restart both after changing this section. +Both the server and the ingestor read `geo_filter` from `config.json`. Restart both after changing this section manually. To disable filtering entirely, remove the `geo_filter` block. @@ -51,50 +51,64 @@ An older bounding box format is also supported as a fallback when no `polygon` i Prefer the polygon format β€” it supports irregular shapes and the `bufferKm` margin. -## API endpoint +## Configuring via the customizer -The current geo filter configuration is exposed at: +If your server has an `apiKey` configured, the **GeoFilter tab** in the Customizer lets you edit the polygon visually without touching `config.json`: -``` -GET /api/config/geo-filter -``` +1. Open the Customizer (nav bar β†’ customize icon) +2. Click the **πŸ—ΊοΈ GeoFilter** tab +3. Click on the map to draw your polygon (at least 3 points) +4. Adjust **Buffer km** +5. Enter your **Server API Key** (the `apiKey` value from `config.json`) +6. Click **Save to server** β€” the filter is applied immediately, no restart needed -The frontend reads this endpoint to display the active filter. No authentication is required (the endpoint returns config, not private data). +The editing controls only appear when the server has a write-capable API key configured. On deployments without an `apiKey`, the tab shows the current polygon as read-only. -## GeoFilter Builder +To remove the filter, click **Remove filter** (also requires the API key). -The simplest way to create a polygon is the included visual builder: +## GeoFilter Builder (standalone tool) -**File:** `tools/geofilter-builder.html` +For a full-screen editing experience, use the built-in GeoFilter Builder at `/geofilter-builder.html`: -Open it directly in a browser β€” it runs entirely client-side, no server required: +1. Navigate to `http://your-server/geofilter-builder.html` +2. Click on the map to add polygon vertices +3. Adjust **Buffer km** (default 20) +4. Copy the generated JSON from the output panel +5. Paste it as a top-level key into `config.json` and restart the server + +The builder is also accessible from the Customizer's Export tab via the **GeoFilter Builder β†’** link. + +For local/offline use without a running server, open `tools/geofilter-builder.html` directly in a browser. + +## API endpoint -```bash -# From the project root -open tools/geofilter-builder.html # macOS -xdg-open tools/geofilter-builder.html # Linux -start tools/geofilter-builder.html # Windows +``` +GET /api/config/geo-filter +``` + +Returns the current geo filter configuration. Also includes a `writeEnabled` boolean indicating whether the `PUT` endpoint is available (i.e., server has a write-capable `apiKey`). + +``` +PUT /api/config/geo-filter ``` -**Workflow:** +Requires `X-API-Key` header. Saves the polygon to `config.json` and applies it in-memory immediately. -1. The map opens centered on Belgium by default. Navigate to your region. -2. Click on the map to add polygon vertices. Each click adds a numbered point. -3. Add at least 3 points to form a closed polygon. -4. Adjust **Buffer km** (default 20) to add a margin around the polygon edge. -5. The generated JSON block appears at the bottom of the page β€” copy it directly into `config.json`. -6. Use **↩ Undo** to remove the last point, **βœ• Clear** to start over. +Request body: +```json +{"polygon": [[lat, lon], ...], "bufferKm": 20} +``` -The output is a complete `{ "geo_filter": { ... } }` block ready to paste into `config.json`. +To clear the filter, send `{"polygon": null}`. ## Cleaning up historical nodes -The ingestor prevents new out-of-bounds nodes from being ingested, but it does not retroactively remove nodes that were stored before the filter was configured. For that, use the prune script. +The ingestor prevents new out-of-bounds nodes from being ingested, but it does not retroactively remove nodes stored before the filter was configured. For that, use the prune script: **File:** `scripts/prune-nodes-outside-geo-filter.py` ```bash -# Dry run β€” shows what would be deleted without making any changes +# Dry run β€” shows what would be deleted without making changes python3 scripts/prune-nodes-outside-geo-filter.py --dry-run # Default paths: /app/data/meshcore.db and /app/config.json @@ -104,11 +118,11 @@ python3 scripts/prune-nodes-outside-geo-filter.py python3 scripts/prune-nodes-outside-geo-filter.py /path/to/meshcore.db \ --config /path/to/config.json -# In Docker β€” run inside the container +# In Docker docker exec -it meshcore-analyzer \ python3 /app/scripts/prune-nodes-outside-geo-filter.py --dry-run ``` -The script reads `geo_filter.polygon` and `geo_filter.bufferKm` from config, lists the nodes that fall outside, then asks for `yes` confirmation before deleting. Nodes without coordinates are always kept. +The script reads `geo_filter.polygon` and `geo_filter.bufferKm` from config, lists nodes that fall outside, then asks for `yes` confirmation before deleting. Nodes without coordinates are always kept. -This is a **one-time migration tool** β€” run it once after first configuring `geo_filter` to clean up pre-filter data. The ingestor handles all subsequent filtering automatically at ingest time. +This is a **one-time migration tool** β€” run it once after first configuring `geo_filter` to clean up pre-filter data. The ingestor handles all subsequent filtering automatically. diff --git a/public/customize-v2.js b/public/customize-v2.js index 86295463..1979aeaa 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -784,6 +784,14 @@ var _activeTab = 'branding'; var _styleEl = null; + // GeoFilter tab state + var _gfMap = null; + var _gfPoints = []; + var _gfMarkers = []; + var _gfPolygon = null; + var _gfClosingLine = null; + var _gfLoaded = false; // true after initial server load + function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; } function escAttr(s) { return (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/' + n + '' : ''; })() }, { id: 'home', label: '🏠', title: 'Home', badge: _tabBadge('home') }, { id: 'display', label: 'πŸ–₯️', title: 'Display', badge: (function () { var n = _countOverrides('timestamps') + (_isOverridden(null, 'distanceUnit') ? 1 : 0); return n ? ' ' + n + '' : ''; })() }, + { id: 'geofilter', label: 'πŸ—ΊοΈ', title: 'GeoFilter' }, { id: 'export', label: 'πŸ“€', title: 'Export' } ]; return '
' + tabs.map(function (t) { @@ -1159,6 +1168,170 @@ '
'; } + function _renderGeoFilter() { + return '
' + + '

Geographic Filter

' + + '

Shows the active geographic filter. Nodes outside this area are excluded at ingest time and in API responses.

' + + '
' + + '
Loading current filter…
' + + // Edit controls β€” hidden until server confirms write access (writeEnabled=true) + '' + + '
'; + } + + function _gfRender() { + if (!_gfMap) return; + if (_gfPolygon) { _gfMap.removeLayer(_gfPolygon); _gfPolygon = null; } + if (_gfClosingLine) { _gfMap.removeLayer(_gfClosingLine); _gfClosingLine = null; } + _gfMarkers.forEach(function (m) { _gfMap.removeLayer(m); }); + _gfMarkers = []; + + _gfPoints.forEach(function (pt, i) { + var m = L.circleMarker(pt, { radius: 6, color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.9 }) + .addTo(_gfMap) + .bindTooltip(String(i + 1), { permanent: true, direction: 'top', offset: [0, -8] }); + _gfMarkers.push(m); + }); + + if (_gfPoints.length >= 3) { + _gfPolygon = L.polygon(_gfPoints, { color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.12 }).addTo(_gfMap); + } else if (_gfPoints.length === 2) { + _gfClosingLine = L.polyline(_gfPoints, { color: '#4a9eff', weight: 2, dashArray: '5,5' }).addTo(_gfMap); + } + } + + function _gfStatus(container, msg) { + var el = container.querySelector('#cv2-gf-status'); + if (el) el.textContent = msg; + } + + function _gfMsg(container, msg, ok) { + var el = container.querySelector('#cv2-gf-msg'); + if (!el) return; + el.textContent = msg; + el.style.display = msg ? '' : 'none'; + el.style.color = ok ? 'var(--status-green)' : 'var(--status-red)'; + } + + function _gfSave(container) { + if (_gfPoints.length < 3) { _gfMsg(container, 'Need at least 3 polygon points.', false); return; } + var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || ''; + if (!apiKey) { _gfMsg(container, 'API key required to save.', false); return; } + var bufferKm = parseFloat((container.querySelector('#cv2-gf-buffer') || {}).value) || 0; + fetch('/api/config/geo-filter', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, + body: JSON.stringify({ polygon: _gfPoints, bufferKm: bufferKm }) + }).then(function (r) { + if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); }); + _gfMsg(container, 'Saved. Filter is active immediately.', true); + _gfStatus(container, _gfPoints.length + ' points Β· bufferKm=' + bufferKm + ' Β· saved'); + }).catch(function (e) { _gfMsg(container, 'Error: ' + e.message, false); }); + } + + function _gfRemove(container) { + var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || ''; + if (!apiKey) { _gfMsg(container, 'API key required.', false); return; } + if (!confirm('Remove geo filter? All nodes will be allowed through.')) return; + fetch('/api/config/geo-filter', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, + body: JSON.stringify({ polygon: null }) + }).then(function (r) { + if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); }); + _gfPoints = []; _gfLoaded = true; + _gfRender(); + _gfStatus(container, 'No geo filter. Click the map to draw a polygon.'); + _gfMsg(container, 'Geo filter removed.', true); + }).catch(function (e) { _gfMsg(container, 'Error: ' + e.message, false); }); + } + + function _initGeoFilterTab(container) { + var mapEl = container.querySelector('#cv2-gf-map'); + if (!mapEl || typeof L === 'undefined') return; + + _gfMap = L.map(mapEl, { zoomControl: true }); + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: 'Β© OpenStreetMap Β© CartoDB', maxZoom: 19 + }).addTo(_gfMap); + + if (!_gfLoaded) { + api('/config/geo-filter', { ttl: 0 }).then(function (gf) { + // Show edit controls only on servers that have a write-capable API key configured + if (gf && gf.writeEnabled) { + var editEl = container.querySelector('#cv2-gf-edit'); + if (editEl) editEl.style.display = ''; + } + if (gf && gf.polygon && gf.polygon.length >= 3) { + _gfPoints = gf.polygon.map(function (p) { return [p[0], p[1]]; }); + var buf = container.querySelector('#cv2-gf-buffer'); + if (buf) buf.value = gf.bufferKm || 0; + _gfRender(); + if (_gfPolygon) _gfMap.fitBounds(_gfPolygon.getBounds(), { padding: [20, 20] }); + _gfStatus(container, gf.polygon.length + ' points Β· bufferKm=' + (gf.bufferKm || 0)); + } else { + _gfPoints = []; + _gfStatus(container, gf && gf.writeEnabled ? 'No geo filter. Click the map to draw a polygon.' : 'No geo filter configured.'); + _gfMap.setView([50.5, 4.4], 5); + } + _gfLoaded = true; + setTimeout(function () { if (_gfMap) _gfMap.invalidateSize(); }, 100); + }).catch(function () { + _gfStatus(container, 'Could not load current filter.'); + _gfMap.setView([50.5, 4.4], 5); + _gfLoaded = true; + setTimeout(function () { if (_gfMap) _gfMap.invalidateSize(); }, 100); + }); + } else { + if (_gfPoints.length >= 3) { + _gfRender(); + if (_gfPolygon) _gfMap.fitBounds(_gfPolygon.getBounds(), { padding: [20, 20] }); + _gfStatus(container, _gfPoints.length + ' points.'); + } else { + _gfMap.setView([50.5, 4.4], 5); + _gfStatus(container, _gfPoints.length ? _gfPoints.length + ' points (need at least 3).' : 'Click the map to draw a polygon.'); + _gfRender(); + } + setTimeout(function () { if (_gfMap) _gfMap.invalidateSize(); }, 100); + } + + _gfMap.on('click', function (e) { + _gfPoints.push([parseFloat(e.latlng.lat.toFixed(6)), parseFloat(e.latlng.lng.toFixed(6))]); + _gfRender(); + _gfStatus(container, _gfPoints.length + ' point' + (_gfPoints.length !== 1 ? 's' : '') + '.'); + }); + + container.querySelector('#cv2-gf-undo').addEventListener('click', function () { + if (!_gfPoints.length) return; + _gfPoints.pop(); + _gfRender(); + _gfStatus(container, _gfPoints.length + ' point' + (_gfPoints.length !== 1 ? 's' : '') + '.'); + }); + container.querySelector('#cv2-gf-clear-pts').addEventListener('click', function () { + _gfPoints = []; + _gfRender(); + _gfStatus(container, 'Cleared. Click the map to draw a polygon.'); + }); + container.querySelector('#cv2-gf-save').addEventListener('click', function () { _gfSave(container); }); + container.querySelector('#cv2-gf-remove').addEventListener('click', function () { _gfRemove(container); }); + } + function _renderExport() { var delta = readOverrides(); var json = JSON.stringify(delta, null, 2); @@ -1193,6 +1366,7 @@ _renderNodes() + _renderHome() + _renderDisplay() + + _renderGeoFilter() + _renderExport() + ''; _bindEvents(container); @@ -1261,11 +1435,15 @@ // Tab switching container.querySelectorAll('.cust-tab').forEach(function (btn) { btn.addEventListener('click', function () { + if (_gfMap) { _gfMap.remove(); _gfMap = null; _gfMarkers = []; _gfPolygon = null; _gfClosingLine = null; } _activeTab = btn.dataset.tab; _renderPanel(container); }); }); + // GeoFilter tab init + if (_activeTab === 'geofilter') _initGeoFilterTab(container); + // Preset buttons container.querySelectorAll('.cust-preset-btn').forEach(function (btn) { btn.addEventListener('click', function () { @@ -1530,7 +1708,10 @@ ''; document.body.appendChild(_panelEl); - _panelEl.querySelector('.cust-close').addEventListener('click', function () { _panelEl.classList.add('hidden'); }); + _panelEl.querySelector('.cust-close').addEventListener('click', function () { + if (_gfMap) { _gfMap.remove(); _gfMap = null; _gfMarkers = []; _gfPolygon = null; _gfClosingLine = null; } + _panelEl.classList.add('hidden'); + }); // Drag support var header = _panelEl.querySelector('.cust-header'); From e92a2333f2dbc577421ff121d361b083ef6bc433 Mon Sep 17 00:00:00 2001 From: efiten Date: Tue, 14 Apr 2026 10:50:42 +0200 Subject: [PATCH 2/3] feat: geofilter map modal + light tile theme (#669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the small inline map in the customizer GeoFilter tab now opens a full-screen modal (92vw Γ— 86vh) with Undo/Clear/Done/Cancel controls. The inline map becomes a read-only preview. Both maps and the standalone geofilter-builder.html now use CartoDB Positron (light) instead of dark. Co-Authored-By: Claude Sonnet 4.6 --- public/customize-v2.js | 176 +++++++++++++++++++++++++++++----- public/geofilter-builder.html | 43 ++++----- 2 files changed, 171 insertions(+), 48 deletions(-) diff --git a/public/customize-v2.js b/public/customize-v2.js index 1979aeaa..003f5514 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -786,6 +786,8 @@ // GeoFilter tab state var _gfMap = null; + var _gfModalMap = null; + var _gfWriteEnabled = false; var _gfPoints = []; var _gfMarkers = []; var _gfPolygon = null; @@ -1172,15 +1174,15 @@ return '
' + '

Geographic Filter

' + '

Shows the active geographic filter. Nodes outside this area are excluded at ingest time and in API responses.

' + - '
' + + '
' + + '
' + + '
πŸ” click to expand
' + + '
' + '
Loading current filter…
' + // Edit controls β€” hidden until server confirms write access (writeEnabled=true) '