Skip to content

Commit d729078

Browse files
authored
Merge pull request #2115 from bruschill/fix/mesh-map-region-persistence
fix(map): restore last-viewed region on launch (fixes (0,0) cold-open)
2 parents e9c54e9 + 724e97f commit d729078

5 files changed

Lines changed: 69 additions & 5 deletions

File tree

Meshtastic/Resources/docs/index.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -240,21 +240,21 @@
240240
"navOrder": 6,
241241
"keywords": [
242242
"map",
243-
"tap",
244243
"nodes",
244+
"tap",
245245
"node",
246246
"from",
247247
"waypoint",
248248
"show",
249249
"only",
250250
"coverage",
251251
"position",
252+
"radio",
252253
"mesh",
254+
"location",
253255
"site",
254-
"radio",
255256
"open",
256257
"lora",
257-
"location",
258258
"estimate",
259259
"planner",
260260
"path",
@@ -268,9 +268,9 @@
268268
"bounding",
269269
"waypoints",
270270
"via",
271-
"route"
271+
"then"
272272
],
273-
"charCount": 8086
273+
"charCount": 8385
274274
},
275275
{
276276
"id": "messages",

Meshtastic/Resources/docs/markdown/user/map.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ nav_order: 6
88

99
The Map tab shows all nodes that have shared a position, overlaid on an Apple Maps base layer.
1010

11+
The map **reopens where you last left it** — your last-viewed area and zoom are remembered between launches, so you don't have to pan back after every relaunch. On a fresh install (before you've moved the map) it frames your current location, then your connected radio, then the center of your nodes.
12+
1113
## Node Pins
1214

1315
Each node that has reported a GPS position appears as a colored circle pin on the map. The **green solid line** shows a directly connected node; **orange dashed lines** show nodes reached via the mesh. A purple star marks a waypoint. Tap a pin to see the node name, last heard time, signal info, and a shortcut to send a direct message.

Meshtastic/Resources/docs/user/map.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<body data-page="map">
1010
<h1>Map &amp; Waypoints</h1>
1111
<p>The Map tab shows all nodes that have shared a position, overlaid on an Apple Maps base layer.</p>
12+
<p>The map <strong>reopens where you last left it</strong> — your last-viewed area and zoom are remembered between launches, so you don't have to pan back after every relaunch. On a fresh install (before you've moved the map) it frames your current location, then your connected radio, then the center of your nodes.</p>
1213
<h2>Node Pins</h2>
1314
<p>Each node that has reported a GPS position appears as a colored circle pin on the map. The <strong>green solid line</strong> shows a directly connected node; <strong>orange dashed lines</strong> show nodes reached via the mesh. A purple star marks a waypoint. Tap a pin to see the node name, last heard time, signal info, and a shortcut to send a direct message.</p>
1415
<p>Pins update automatically when a new position packet is received from the mesh.</p>

Meshtastic/Views/Nodes/MeshMapMK.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ struct MeshMapMK: View {
4949
// Map Configuration
5050
@Namespace var mapScope
5151
@AppStorage("meshMapDistance") private var meshMapDistance: Double = 800000
52+
// Persisted last-viewed camera region, so the map reopens where the user left it -- independent of
53+
// GPS / connected device / node availability at launch. This is what fixes the cold-open default
54+
// (0,0) "South Atlantic" view for a location-denied user with no positioned nodes. Stored as four
55+
// primitives because `MKCoordinateRegion` isn't `Codable`; a non-positive saved span means
56+
// "nothing saved yet".
57+
@AppStorage("meshMapRegionCenterLatitude") private var savedRegionCenterLatitude: Double = 0
58+
@AppStorage("meshMapRegionCenterLongitude") private var savedRegionCenterLongitude: Double = 0
59+
@AppStorage("meshMapRegionSpanLatitude") private var savedRegionSpanLatitude: Double = 0
60+
@AppStorage("meshMapRegionSpanLongitude") private var savedRegionSpanLongitude: Double = 0
5261
@State var mapStyle: MapStyle = MapStyle.standard(elevation: .flat, emphasis: MapStyle.StandardEmphasis.muted, pointsOfInterest: .excludingAll, showsTraffic: false)
5362
@State var position = MapCameraPosition.automatic
5463
@State private var distance = 10000.0
@@ -565,6 +574,8 @@ struct MeshMapMK: View {
565574
// Pan/zoom settled: re-filter to the new region now; the state key dedupes
566575
// the rebuild when the visible set is unchanged.
567576
refreshPositionState()
577+
// Remember where the user is looking so the map reopens here next launch.
578+
persistVisibleRegion()
568579
}
569580
.onChange(of: accessoryManager.activeDeviceNum) {
570581
syncFallbackLocation()
@@ -868,6 +879,17 @@ struct MeshMapMK: View {
868879
/// as positions pour in.
869880
private func frameInitialRegionIfNeeded() {
870881
guard !didInitialFrame else { return }
882+
// Restore the user's last-viewed region first: anyone who has ever moved the map reopens
883+
// exactly where they left it, with no dependence on GPS / connected device / node data at
884+
// launch. This is the only branch that resolves the reported cold-open (0,0) default for a
885+
// location-denied user with no positioned nodes -- and it consumes the one-shot, so the user
886+
// still owns the camera immediately afterward.
887+
if let saved = savedMapRegion {
888+
didInitialFrame = true
889+
visibleRegion = saved
890+
cameraCommand = ClusterMapCameraCommand(id: UUID(), region: saved)
891+
return
892+
}
871893
// Frame from the same set the map actually shows, so "Precise Locations Only" doesn't start
872894
// the camera centered on hidden reduced-precision nodes.
873895
let nodeCoords = mapEligiblePositions.compactMap { $0.nodeCoordinate ?? $0.fuzzedNodeCoordinate }
@@ -894,6 +916,43 @@ struct MeshMapMK: View {
894916
cameraCommand = ClusterMapCameraCommand(id: UUID(), region: region)
895917
}
896918

919+
/// The persisted last-viewed camera region, or `nil` when nothing valid has been saved yet
920+
/// (fresh install, or a degenerate/near-global region we deliberately never store).
921+
private var savedMapRegion: MKCoordinateRegion? {
922+
let latDelta = savedRegionSpanLatitude, lonDelta = savedRegionSpanLongitude
923+
guard latDelta > 0, lonDelta > 0 else { return nil }
924+
let lat = savedRegionCenterLatitude, lon = savedRegionCenterLongitude
925+
guard lat.isFinite, lon.isFinite, (-90...90).contains(lat), (-180...180).contains(lon) else {
926+
return nil
927+
}
928+
return MKCoordinateRegion(
929+
center: CLLocationCoordinate2D(latitude: lat, longitude: lon),
930+
span: MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta)
931+
)
932+
}
933+
934+
/// Persist the current camera region so the next launch reopens here. Deliberately skips the
935+
/// uninitialized/near-global region MapKit reports before the first real frame (its whole-world
936+
/// default approaches a 180-degree latitude span) so we never save the (0,0) cold-open we're
937+
/// fixing; genuine local pans/zooms always fall well under the guard.
938+
private func persistVisibleRegion() {
939+
guard let region = visibleRegion else { return }
940+
let latDelta = region.span.latitudeDelta, lonDelta = region.span.longitudeDelta
941+
guard latDelta > 0, latDelta < 90, lonDelta > 0, lonDelta < 180 else { return }
942+
let center = region.center
943+
guard center.latitude.isFinite, center.longitude.isFinite else { return }
944+
// Also reject MapKit's *intermediate* pre-interaction default, which parks a broad span on
945+
// (0,0). A wide region still centered at Null Island is the cold-open we're fixing -- never a
946+
// real data-driven frame (those are small-span) nor a user pan (whose center has moved off the
947+
// equator/prime meridian). A genuine local view near (0,0) is small-span and still saves.
948+
let nearNullIsland = abs(center.latitude) < 1 && abs(center.longitude) < 1
949+
guard !(nearNullIsland && (latDelta > 10 || lonDelta > 10)) else { return }
950+
savedRegionCenterLatitude = center.latitude
951+
savedRegionCenterLongitude = center.longitude
952+
savedRegionSpanLatitude = latDelta
953+
savedRegionSpanLongitude = lonDelta
954+
}
955+
897956
/// Average of a coordinate list (nil when empty).
898957
private func coordinateCentroid(of coords: [CLLocationCoordinate2D]) -> CLLocationCoordinate2D? {
899958
guard !coords.isEmpty else { return nil }

docs/user/map.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ nav_order: 6
88

99
The Map tab shows all nodes that have shared a position, overlaid on an Apple Maps base layer.
1010

11+
The map **reopens where you last left it** — your last-viewed area and zoom are remembered between launches, so you don't have to pan back after every relaunch. On a fresh install (before you've moved the map) it frames your current location, then your connected radio, then the center of your nodes.
12+
1113
## Node Pins
1214

1315
Each node that has reported a GPS position appears as a colored circle pin on the map. The **green solid line** shows a directly connected node; **orange dashed lines** show nodes reached via the mesh. A purple star marks a waypoint. Tap a pin to see the node name, last heard time, signal info, and a shortcut to send a direct message.

0 commit comments

Comments
 (0)