From 3e215bcb92d2e7b9dbfa36cd1049b0e20e305778 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Mon, 20 Jul 2026 12:10:23 -0700 Subject: [PATCH] feat(map): My Location toggle, user-tracking button, distance-based cluster fan-out Recovers uncommitted follow-up work to the MKMapView map (#1989) and rebases it onto current main. - Add a "My Location" toggle (persisted `enableMapUserLocation`) to Map settings. - MeshMapMK: bind `showUserLocation` to that setting and gate the blue dot on `isMapVisible` so it turns off while the tab is off-screen/backgrounded. - ClusterMapView: add a native MKUserTrackingButton (follow/center), hidden when the user-location dot is off; floor the cluster zoom span (~140 m) so a tight "2" cluster still breaks apart to a readable street level. - MeshMapMK: replace grid-quantized spread grouping with distance-based union-find (16 m) so stacked pins straddling a grid cell or jittering a few meters apart are reliably fanned out into individual tappable circles. Co-Authored-By: Claude Opus 4.8 (1M context) --- Localizable.xcstrings | 3 + .../Nodes/Helpers/Map/ClusterMapView.swift | 35 ++++++++- .../Nodes/Helpers/Map/MapSettingsForm.swift | 10 +++ Meshtastic/Views/Nodes/MeshMapMK.swift | 74 ++++++++++++++----- 4 files changed, 102 insertions(+), 20 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 8cbc0d3d7..e0cfd1dc0 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -58674,6 +58674,9 @@ "Muted" : { "comment" : "VoiceOver: channel notifications are muted" }, + "My Location" : { + "comment" : "Map settings toggle: show the device's own location (blue dot) and the follow control" + }, "NTP Server" : { "localizations" : { "uk" : { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift b/Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift index e5d86cc35..4d078bf81 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift @@ -393,6 +393,8 @@ struct ClusterMapView: UIViewRepre private weak var hostMapView: MKMapView? private var controlsStack: UIStackView? private var controlsBottomConstraint: NSLayoutConstraint? + /// Native follow/center control; hidden when `showsUserLocation` is off so it isn't a dead button. + private weak var userTrackingButton: MKUserTrackingButton? /// id → standalone decoration annotation (route markers, waypoints) currently on the map. private var decorationsByID: [AnyHashable: DecorationAnnotation] = [:] @@ -461,6 +463,8 @@ struct ClusterMapView: UIViewRepre } mapView.showsUserLocation = config.showsUserLocation + // The tracking button is only meaningful when the user-location dot is shown. + userTrackingButton?.isHidden = !config.showsUserLocation mapView.showsScale = config.showsScale // A custom compass + pitch toggle are installed in `installControls` (bottom-trailing, above // the caller's button bar); the built-in top-right compass is disabled there. @@ -497,7 +501,25 @@ struct ClusterMapView: UIViewRepre pitch.heightAnchor.constraint(equalToConstant: 44) ]) - let stack = UIStackView(arrangedSubviews: [pitch, compass]) + // Native follow/center control — MapKit owns the user-location camera behavior. Styled to + // match the pitch button; hidden while the user-location dot is off (see applyConfiguration). + let tracking = MKUserTrackingButton(mapView: mapView) + tracking.tintColor = .label + tracking.backgroundColor = UIColor.tertiarySystemBackground.withAlphaComponent(0.92) + tracking.layer.cornerRadius = 8 + tracking.layer.shadowColor = UIColor.black.cgColor + tracking.layer.shadowOpacity = 0.2 + tracking.layer.shadowRadius = 2 + tracking.layer.shadowOffset = CGSize(width: 0, height: 1) + tracking.translatesAutoresizingMaskIntoConstraints = false + tracking.isHidden = !(appliedConfiguration?.showsUserLocation ?? true) + NSLayoutConstraint.activate([ + tracking.widthAnchor.constraint(equalToConstant: 44), + tracking.heightAnchor.constraint(equalToConstant: 44) + ]) + userTrackingButton = tracking + + let stack = UIStackView(arrangedSubviews: [tracking, pitch, compass]) stack.axis = .vertical stack.spacing = 10 stack.alignment = .center @@ -749,7 +771,16 @@ struct ClusterMapView: UIViewRepre acc.union(MKMapRect(origin: MKMapPoint(member.coordinate), size: MKMapSize(width: 0, height: 0))) } if !rect.isNull { - let padded = rect.insetBy(dx: -rect.size.width * 0.3 - 1, dy: -rect.size.height * 0.3 - 1) + // Pad generously so members land well inside the viewport. + var padded = rect.insetBy(dx: -max(rect.size.width * 0.8, 1), dy: -max(rect.size.height * 0.8, 1)) + // Floor the span (~140 m) so a TIGHT cluster still zooms in to a readable street + // level instead of an imperceptible nudge — otherwise a stubborn "2" never breaks. + let pointsPerMeter = MKMapPointsPerMeterAtLatitude(cluster.coordinate.latitude) + let minSpan = 140.0 * pointsPerMeter + if padded.size.width < minSpan || padded.size.height < minSpan { + let span = max(minSpan, max(padded.size.width, padded.size.height)) + padded = MKMapRect(x: padded.midX - span / 2, y: padded.midY - span / 2, width: span, height: span) + } mapView.setVisibleMapRect(padded, animated: true) } mapView.deselectAnnotation(cluster, animated: false) diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift index 121b7627a..2dcdf5527 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift @@ -16,6 +16,7 @@ struct MapSettingsForm: View { @AppStorage("meshMapShowRouteLines") private var enableMapRouteLines = false @AppStorage("enableMapConvexHull") private var convexHull = false @AppStorage("enableMapWaypoints") private var enableMapWaypoints = true + @AppStorage("enableMapUserLocation") private var enableMapUserLocation = true @AppStorage("mapOverlaysEnabled") private var mapOverlaysEnabled = false @AppStorage("enableOfflineTiles") private var enableOfflineTiles = false @AppStorage("enableMapClustering") private var enableMapClustering = true @@ -86,6 +87,15 @@ struct MapSettingsForm: View { } } .tint(.accentColor) + Toggle(isOn: $enableMapUserLocation) { + Label { + Text("My Location") + } icon: { + Image(systemName: "location.fill") + .symbolRenderingMode(.multicolor) + } + } + .tint(.accentColor) } if !meshMap { Toggle(isOn: $nodeHistory) { diff --git a/Meshtastic/Views/Nodes/MeshMapMK.swift b/Meshtastic/Views/Nodes/MeshMapMK.swift index 2979b3a1e..d57e9f34a 100644 --- a/Meshtastic/Views/Nodes/MeshMapMK.swift +++ b/Meshtastic/Views/Nodes/MeshMapMK.swift @@ -30,7 +30,10 @@ struct MeshMapMK: View { var showOpenWindowButton: Bool = true /// Parameters - @State var showUserLocation: Bool = true + /// Whether to draw the device's own location (blue dot) + show the user-tracking control. + /// Persisted so the choice survives relaunch; gated on `isMapVisible` at the call site so the + /// dot/tracking turns off while the tab is off-screen or the app is backgrounded. + @AppStorage("enableMapUserLocation") private var showUserLocation: Bool = true /// Map State User Defaults @AppStorage("enableMapTraffic") private var showTraffic: Bool = false @AppStorage("enableMapPointsOfInterest") private var showPointsOfInterest: Bool = false @@ -263,7 +266,7 @@ struct MeshMapMK: View { layer: selectedMapLayer == .offline ? .standard : selectedMapLayer, showsTraffic: showTraffic, showsPointsOfInterest: showPointsOfInterest, - showsUserLocation: showUserLocation, + showsUserLocation: showUserLocation && isMapVisible, controlsBottomInset: 72 // lift compass + pitch toggle above the bottom button bar ) } @@ -794,30 +797,65 @@ struct MeshMapMK: View { rebuildOverlays() } - /// Fan out nodes that sit on (nearly) the same coordinate into a small ring so a stacked cluster - /// always breaks into individual, tappable node circles instead of an un-splittable pin. The - /// accuracy circle stays at the true location; only the pin's display coordinate is offset. + /// Fan out nodes that sit on (nearly) the same spot into a small ring so a stacked cluster always + /// breaks into individual, tappable node circles instead of an un-splittable pin. The accuracy + /// circle stays at the true location; only the pin's display coordinate is offset. + /// + /// Grouping is DISTANCE-based (not cell-quantized): any nodes within `mergeMeters` of each other + /// are fanned out together. A fixed grid missed two real cases that produced un-splittable "2" + /// clusters — pairs straddling a cell boundary, and same-site radios whose GPS fixes jitter a few + /// meters apart — because they're far too close to ever separate by zooming. Union-find over a + /// coarse neighbor grid keeps this ~O(n) on the density-limited snapshot set. private func computeSpreadOverrides(_ snaps: [MeshMapPositionSnapshot]) -> [Int64: CLLocationCoordinate2D] { - var groups: [Int64: [MeshMapPositionSnapshot]] = [:] - for snap in snaps { - // Quantize to ~1 m so only (near-)coincident nodes are grouped together. - let latKey = Int64((snap.coordinate.latitude * 1e5).rounded()) - let lonKey = Int64((snap.coordinate.longitude * 1e5).rounded()) - groups[latKey &* 100_000_000 &+ lonKey, default: []].append(snap) + guard snaps.count > 1 else { return [:] } + let metersPerDegLat = 111_320.0 + /// Pins within this distance are treated as "stacked" and fanned out. Comfortably above GPS + /// jitter and the old ~1 m cell so boundary/jitter pairs are reliably caught. + let mergeMeters = 16.0 + + // Project to a local meter plane (accurate enough at city scale) for distance + bucketing. + let pts = snaps.map { snap -> (x: Double, y: Double) in + let metersPerDegLon = metersPerDegLat * cos(snap.coordinate.latitude * .pi / 180) + return (snap.coordinate.longitude * metersPerDegLon, snap.coordinate.latitude * metersPerDegLat) + } + // Coarse grid (cell == mergeMeters) so each node only compares against neighboring cells. + func cellKey(_ gx: Int, _ gy: Int) -> Int64 { Int64(gx) &* 4_000_000 &+ Int64(gy) } + let grid = pts.map { (Int(($0.x / mergeMeters).rounded(.down)), Int(($0.y / mergeMeters).rounded(.down))) } + var buckets: [Int64: [Int]] = [:] + for (i, cell) in grid.enumerated() { buckets[cellKey(cell.0, cell.1), default: []].append(i) } + + // Union-find: merge any pair within mergeMeters (checking the node's cell + its 8 neighbors). + var parent = Array(0.. Int { var r = a; while parent[r] != r { parent[r] = parent[parent[r]]; r = parent[r] }; return r } + func union(_ a: Int, _ b: Int) { let ra = find(a), rb = find(b); if ra != rb { parent[ra] = rb } } + let mergeSq = mergeMeters * mergeMeters + for i in 0.. i { + let ddx = pts[i].x - pts[j].x, ddy = pts[i].y - pts[j].y + if ddx * ddx + ddy * ddy <= mergeSq { union(i, j) } + } + }} } + + // Collect groups by root, fan each group with >1 member around its centroid. + var groups: [Int: [Int]] = [:] + for i in 0.. 1 { - let sorted = members.sorted { $0.nodeNum < $1.nodeNum } - let base = sorted[0].coordinate - let metersPerDegLon = max(1.0, metersPerDegLat * cos(base.latitude * .pi / 180)) + let sorted = members.sorted { snaps[$0].nodeNum < snaps[$1].nodeNum } + let centerLat = sorted.reduce(0.0) { $0 + snaps[$1].coordinate.latitude } / Double(sorted.count) + let centerLon = sorted.reduce(0.0) { $0 + snaps[$1].coordinate.longitude } / Double(sorted.count) + let metersPerDegLon = max(1.0, metersPerDegLat * cos(centerLat * .pi / 180)) // Grow the ring with crowd size so even a big pile stays individually tappable. let radius = 14.0 + Double(sorted.count) * 1.5 for (index, member) in sorted.enumerated() { let angle = 2 * Double.pi * Double(index) / Double(sorted.count) - overrides[member.nodeNum] = CLLocationCoordinate2D( - latitude: base.latitude + (radius * sin(angle)) / metersPerDegLat, - longitude: base.longitude + (radius * cos(angle)) / metersPerDegLon + overrides[snaps[member].nodeNum] = CLLocationCoordinate2D( + latitude: centerLat + (radius * sin(angle)) / metersPerDegLat, + longitude: centerLon + (radius * cos(angle)) / metersPerDegLon ) } }