From aa69eb6ec9941ea828cdcbbbd634ef6aa942a695 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Fri, 26 Jun 2026 20:27:35 -0400 Subject: [PATCH] Primary region and details page cleanup --- internal/core/config_flow_test.go | 4 +- internal/core/config_profile.go | 15 +++- internal/core/templates/config_edit.html | 16 +++- internal/store/config_profiles.go | 24 ++--- internal/store/config_profiles_test.go | 7 -- .../store/migrations/0029_region_primary.sql | 7 ++ internal/web/orgview.go | 89 +++++++++++-------- internal/web/static/regionmap.js | 41 ++++++--- internal/web/templates/org_config.html | 85 +++++------------- 9 files changed, 145 insertions(+), 143 deletions(-) create mode 100644 internal/store/migrations/0029_region_primary.sql diff --git a/internal/core/config_flow_test.go b/internal/core/config_flow_test.go index 3247a09..7b63bb6 100644 --- a/internal/core/config_flow_test.go +++ b/internal/core/config_flow_test.go @@ -46,7 +46,9 @@ func TestOrgConfigProfilesFlow(t *testing.T) { } body := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", sess)) - for _, want := range []string{"ESP32", "nRF52", "Mountains", " @@ -106,7 +118,9 @@ function removeBlock(btn, cls) { btn.closest('.' + cls).remove(); }
- Applies everywhere + + + Applies everywhere
diff --git a/internal/store/config_profiles.go b/internal/store/config_profiles.go index e953c2e..376f50f 100644 --- a/internal/store/config_profiles.go +++ b/internal/store/config_profiles.go @@ -51,6 +51,7 @@ type Region struct { Token string DisplayName string Layer int + Primary bool // the org's primary region (frames the config preview map) Geofence *geo.Shape GeofenceJSON []byte } @@ -65,6 +66,7 @@ type RegionInput struct { Token string DisplayName string Layer int + Primary bool GeofenceJSON []byte } @@ -126,14 +128,14 @@ func (s *Store) ListProfiles(ctx context.Context, orgID int64) ([]Profile, error // the order their tokens appear in a `region def` chain. func (s *Store) ListRegions(ctx context.Context, orgID int64) ([]Region, error) { rrows, err := s.pool.Query(ctx, - `SELECT id, token, display_name, layer, geofence FROM config_regions WHERE org_id = $1 ORDER BY layer, token`, orgID) + `SELECT id, token, display_name, layer, is_primary, geofence FROM config_regions WHERE org_id = $1 ORDER BY layer, token`, orgID) if err != nil { return nil, fmt.Errorf("list regions: %w", err) } regions, err := collectRows(rrows, func(r pgx.Row) (Region, error) { var z Region var raw []byte - if err := r.Scan(&z.ID, &z.Token, &z.DisplayName, &z.Layer, &raw); err != nil { + if err := r.Scan(&z.ID, &z.Token, &z.DisplayName, &z.Layer, &z.Primary, &raw); err != nil { return Region{}, err } if z.Geofence, err = geo.Parse(raw); err != nil { @@ -177,8 +179,8 @@ func (s *Store) ReplaceOrgConfig(ctx context.Context, orgID int64, profiles []Pr geofence = z.GeofenceJSON } if _, err := tx.Exec(ctx, - `INSERT INTO config_regions (org_id, token, display_name, layer, geofence) VALUES ($1, $2, $3, $4, $5)`, - orgID, z.Token, z.DisplayName, z.Layer, geofence); err != nil { + `INSERT INTO config_regions (org_id, token, display_name, layer, is_primary, geofence) VALUES ($1, $2, $3, $4, $5, $6)`, + orgID, z.Token, z.DisplayName, z.Layer, z.Primary, geofence); err != nil { return fmt.Errorf("insert region %q: %w", z.Token, err) } } @@ -236,20 +238,6 @@ func betterParent(a, b Region) bool { return a.Token < b.Token } -// RegionParentTokens returns each region's parent token ("" for a root), aligned -// with regions, using the same overlap-based parentage as the region def chain — -// for showing the derived hierarchy in the editor/read-only views. -func RegionParentTokens(regions []Region) []string { - parents := regionParents(regions) - out := make([]string, len(regions)) - for i, p := range parents { - if p != -1 { - out[i] = regions[p].Token - } - } - return out -} - // RegionDefCommands renders the regions that apply at (lat, lon) into the MeshCore // commands to run on a repeater: a single `region def …` line describing the // region tree for the location, followed by `region save`. The tree is the subset diff --git a/internal/store/config_profiles_test.go b/internal/store/config_profiles_test.go index c269d3c..3e485a6 100644 --- a/internal/store/config_profiles_test.go +++ b/internal/store/config_profiles_test.go @@ -1,7 +1,6 @@ package store import ( - "reflect" "testing" "github.com/jleight/meshtender/internal/geo" @@ -121,10 +120,4 @@ func TestRegionDefCommands(t *testing.T) { if got := RegionDefCommands(regions, ptr(-5.0), ptr(-5.0)); got != nil { t.Fatalf("outside: got %v, want nil", got) } - - // Parentage is exposed for display, aligned with regions. - wantParents := []string{"", "us", "us", "ny"} - if got := RegionParentTokens(regions); !reflect.DeepEqual(got, wantParents) { - t.Fatalf("parent tokens = %v, want %v", got, wantParents) - } } diff --git a/internal/store/migrations/0029_region_primary.sql b/internal/store/migrations/0029_region_primary.sql new file mode 100644 index 0000000..1456a60 --- /dev/null +++ b/internal/store/migrations/0029_region_primary.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- A region can be marked primary; the config page frames its location-preview +-- map on the primary region (falling back to the org's repeaters when none is set). +ALTER TABLE config_regions ADD COLUMN is_primary BOOLEAN NOT NULL DEFAULT false; + +-- +goose Down +ALTER TABLE config_regions DROP COLUMN is_primary; diff --git a/internal/web/orgview.go b/internal/web/orgview.go index df1207c..bf6ffa5 100644 --- a/internal/web/orgview.go +++ b/internal/web/orgview.go @@ -19,38 +19,36 @@ type ProfileView struct { Steps []store.ConfigStep } -// RegionView is an org region rendered for display: its display name, MeshCore -// token, and layer; the geofence reduced to its bounding box for the text summary -// (empty when it matches everywhere); the raw GeoJSON for the map; plus whether it -// applies at the previewed location. -type RegionView struct { - DisplayName string - Token string - Layer int - Parent string // derived parent token ("" = root) - MatchAll bool - MinLat string - MinLon string - MaxLat string - MaxLon string - GeofenceJSON string - Matches bool -} - // ConfigView is an org's configuration for display: its named profiles (with one -// selected for its base settings) and its regions (location steps), the two kept -// independent. PreviewActive is true when a location was supplied. +// selected for its base settings) and its regions, kept independent. The regions +// themselves aren't listed here — they surface only through the location picker — +// so this carries just what that map needs. PreviewActive is true when a location +// was supplied. type ConfigView struct { HasConfig bool Profiles []ProfileView Selected string SelectedSteps []store.ConfigStep - Regions []RegionView - HasRegionShapes bool // any region has a drawn geofence (worth showing a map) - RegionDef []string // region def/save commands for the previewed location + HasRegions bool // org defines at least one region + HasRegionShapes bool // some region has a geofence (so the picker map is useful) + MapBounds []float64 // {minLat, minLon, maxLat, maxLon} framing all geofences, or nil + RegionDef []string // region def/save commands for the previewed location PreviewActive bool } +// bbox accumulates a lat/lon bounding box. A nil *bbox is empty, so extend can be +// chained from nil to fold in the first point/box. +type bbox struct{ minLat, minLon, maxLat, maxLon float64 } + +func (b *bbox) extend(minLat, minLon, maxLat, maxLon float64) *bbox { + if b == nil { + return &bbox{minLat, minLon, maxLat, maxLon} + } + b.minLat, b.minLon = min(b.minLat, minLat), min(b.minLon, minLon) + b.maxLat, b.maxLon = max(b.maxLat, maxLat), max(b.maxLon, maxLon) + return b +} + // BuildConfigView loads an org's profiles and regions for read-only display. // selected names the profile whose base settings to show (falls back to the // first). lat/lon, when non-nil, mark which regions apply at that location. An @@ -66,6 +64,7 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected } cv := ConfigView{ HasConfig: len(profiles) > 0 || len(regions) > 0, + HasRegions: len(regions) > 0, PreviewActive: lat != nil && lon != nil, } for _, p := range profiles { @@ -82,21 +81,37 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected cv.Selected = profiles[idx].Name cv.SelectedSteps = profiles[idx].Steps } - parentToks := store.RegionParentTokens(regions) - for i, z := range regions { - rv := RegionView{DisplayName: z.DisplayName, Token: z.Token, Layer: z.Layer, Parent: parentToks[i], GeofenceJSON: string(z.GeofenceJSON), Matches: store.RegionMatches(z, lat, lon)} - if minLat, minLon, maxLat, maxLon, ok := z.Geofence.Bounds(); ok { - rv.MinLat = formatCoord(minLat) - rv.MinLon = formatCoord(minLon) - rv.MaxLat = formatCoord(maxLat) - rv.MaxLon = formatCoord(maxLon) - } else { - rv.MatchAll = true - } - if rv.GeofenceJSON != "" { + // Frame the location-preview map. Prefer the primary region's geofence; if + // none is set (or it has no shape), fall back to the org's public repeaters; + // failing that, the union of all geofences. (The picker only renders when + // HasRegionShapes, so a usable box almost always exists.) + var union *bbox + for _, z := range regions { + if len(z.GeofenceJSON) > 0 { cv.HasRegionShapes = true } - cv.Regions = append(cv.Regions, rv) + if a, b, c, d, ok := z.Geofence.Bounds(); ok { + union = union.extend(a, b, c, d) + if z.Primary { + cv.MapBounds = []float64{a, b, c, d} + } + } + } + if cv.MapBounds == nil { + if reps, err := st.ListPublicRepeaters(ctx, orgID); err == nil { + var rb *bbox + for _, rp := range reps { + if rp.HasLocation { + rb = rb.extend(rp.Lat, rp.Lon, rp.Lat, rp.Lon) + } + } + if rb != nil { + cv.MapBounds = []float64{rb.minLat, rb.minLon, rb.maxLat, rb.maxLon} + } + } + } + if cv.MapBounds == nil && union != nil { + cv.MapBounds = []float64{union.minLat, union.minLon, union.maxLat, union.maxLon} } if cv.PreviewActive { cv.RegionDef = store.RegionDefCommands(regions, lat, lon) @@ -104,8 +119,6 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected return cv, nil } -func formatCoord(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) } - // OrgNav is the data the shared org-tabs sub-nav partial expects: the org slug, // which tab is active ("home" | "repeaters" | "members" | "config"), whether the // viewer is a member (the Members tab, which exposes personal info, only shows for diff --git a/internal/web/static/regionmap.js b/internal/web/static/regionmap.js index 56ae305..547db55 100644 --- a/internal/web/static/regionmap.js +++ b/internal/web/static/regionmap.js @@ -221,24 +221,37 @@ // ---- Read-only viewer --------------------------------------------------- - // regions: [{name, geojson}]; preview: {lat, lon} | null. - window.regionMapView = function (mapId, regions, preview) { + // regionMapView renders a location-picker map (no region outlines — those are + // just noise here). opts: + // pickURL — clicking the map navigates here with lat/lon appended so the + // server resolves the region def for that point (must end "?"/"&"); + // preview — {lat, lon} of an already-picked point, shown as a marker; + // bounds — [[minLat,minLon],[maxLat,maxLon]] to frame the org's regions. + window.regionMapView = function (mapId, opts) { + opts = opts || {}; var map = L.map(mapId, { scrollWheelZoom: false }); darkTiles(map); - var layers = []; - (regions || []).forEach(function (z, i) { - var layer = layerFromGeoJSON(z.geojson); - if (!layer) return; - if (layer.setStyle) layer.setStyle(styleFor(i, false)); - layer.bindPopup(z.name).addTo(map); - layers.push(layer); - }); - if (preview) { - var m = L.circleMarker([preview.lat, preview.lon], { + if (opts.pickURL) { + map.on("click", function (e) { + window.location = opts.pickURL + "lat=" + e.latlng.lat.toFixed(6) + "&lon=" + e.latlng.lng.toFixed(6); + }); + } + var fit = []; + if (opts.bounds) fit.push(opts.bounds[0], opts.bounds[1]); + if (opts.preview) { + L.circleMarker([opts.preview.lat, opts.preview.lon], { radius: 7, color: "#fff", weight: 2, fillColor: "#fff", fillOpacity: 0.9, }).addTo(map); - layers.push(m); + fit.push([opts.preview.lat, opts.preview.lon]); + } + if (fit.length) { + var b = L.latLngBounds(fit); + // A zero-size box (single point — one repeater, or a preview with no + // region box) can't be fit; center on it at a neighborhood zoom instead. + if (b.getNorthEast().equals(b.getSouthWest())) map.setView(b.getCenter(), 11, { animate: false }); + else map.fitBounds(b.pad(0.2), { animate: false }); + } else { + map.setView([20, 0], 2, { animate: false }); } - fitToLayers(map, layers, preview ? [preview.lat, preview.lon] : null); }; })(); diff --git a/internal/web/templates/org_config.html b/internal/web/templates/org_config.html index e6709b8..6d7ed56 100644 --- a/internal/web/templates/org_config.html +++ b/internal/web/templates/org_config.html @@ -30,63 +30,20 @@ {{end}} - {{if .Config.Regions}} + {{if .Config.PreviewActive}}
-

Regions

- region hierarchy -
-
- A repeater is assigned to every region whose area contains it. Sorted by layer, their names form a region def command. -
-
- {{if .Config.HasRegionShapes}} -
-
- -
- - - -
-
- {{end}} - {{if .Config.RegionDef}} -
-

region def · at {{.PreviewLat}}, {{.PreviewLon}}

+

Regions

+ {{if .Config.RegionDef}}
{{range .Config.RegionDef}}{{.}}
 {{end}}
-
- {{else if .Config.PreviewActive}} -
No regions cover {{.PreviewLat}}, {{.PreviewLon}}.
- {{end}} -
-
- - {{if .Config.PreviewActive}}{{end}} - - {{range .Config.Regions}} - - - - - - - {{if $.Config.PreviewActive}}{{end}} - - {{end}} - -
Display nameNameLayerParentArea
{{.DisplayName}}{{.Token}}{{.Layer}}{{if .Parent}}{{.Parent}}{{else}}root{{end}}{{if .MatchAll}}everywhere{{else}}lat {{.MinLat}}…{{.MaxLat}}, lon {{.MinLon}}…{{.MaxLon}}{{end}}{{if .Matches}}applies{{end}}
-
+ {{else}} +
No regions cover this location.
+ {{end}}
{{end}} - +
{{if .Config.Profiles}}
@@ -100,24 +57,30 @@ {{if .PreviewLon}}{{end}}

- Pick the profile matching your hardware to see its base settings.{{if .Config.Regions}} A repeater's regions are assigned separately, by location.{{end}} + Pick the profile matching your hardware to see its base settings.{{if .Config.HasRegions}} A repeater's regions are assigned separately, by location.{{end}}

{{end}} - {{if .Config.Regions}} + {{if .Config.HasRegionShapes}}

Preview a location

-
-
- {{if .Config.Profiles}}{{end}} -
-
-
-
-
-
+
+ +
+ + + + {{if .Config.PreviewActive}} +
{{template "icon-map-pin" "me-1"}}{{.PreviewLat}}, {{.PreviewLon}}
+ {{end}}
{{end}}