Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
35 changes: 33 additions & 2 deletions Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,8 @@ struct ClusterMapView<Item: Identifiable, Pin: View, Cluster: View>: 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] = [:]

Expand Down Expand Up @@ -461,6 +463,8 @@ struct ClusterMapView<Item: Identifiable, Pin: View, Cluster: View>: 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.
Expand Down Expand Up @@ -497,7 +501,25 @@ struct ClusterMapView<Item: Identifiable, Pin: View, Cluster: View>: 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])
Comment on lines +504 to +522

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

New tracking button sits inside the waypoint-creation gesture zone — long-press can create an unwanted waypoint.

gestureRecognizer(_:shouldReceive:) (below, unchanged) only rejects touches on MKAnnotationView; it doesn't reject touches on this new MKUserTrackingButton (or the existing pitch button). Combined with shouldRecognizeSimultaneouslyWith always returning true, a long-press directly on the tracking button will also fire handleMapLongPressonMapLongPressbeginNewWaypoint, creating a waypoint under the button.

🐛 Suggested fix — reject touches on any control, not just annotation views (applies at the `gestureRecognizer(_:shouldReceive:)` implementation, ~line 812)
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
    guard let mapView = gestureRecognizer.view as? MKMapView else { return true }
    var hit = mapView.hitTest(touch.location(in: mapView), with: nil)
    while let view = hit, view !== mapView {
        if view is MKAnnotationView || view is UIControl { return false }
        hit = view.superview
    }
    return true
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift` around lines 504 -
522, Update gestureRecognizer(_:shouldReceive:) to walk from the touched view up
to the MKMapView and reject touches on either MKAnnotationView or any UIControl,
including the tracking, pitch, and compass buttons. Preserve accepting touches
that reach the map without matching those excluded view types.

stack.axis = .vertical
stack.spacing = 10
stack.alignment = .center
Expand Down Expand Up @@ -749,7 +771,16 @@ struct ClusterMapView<Item: Identifiable, Pin: View, Cluster: View>: 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)
Expand Down
10 changes: 10 additions & 0 deletions Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
74 changes: 56 additions & 18 deletions Meshtastic/Views/Nodes/MeshMapMK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}
Expand Down Expand Up @@ -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..<snaps.count)
func find(_ a: Int) -> 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..<snaps.count {
let (gx, gy) = grid[i]
for dx in -1...1 { for dy in -1...1 {
guard let neighbors = buckets[cellKey(gx + dx, gy + dy)] else { continue }
for j in neighbors where j > 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..<snaps.count { groups[find(i), default: []].append(i) }
var overrides: [Int64: CLLocationCoordinate2D] = [:]
let metersPerDegLat = 111_320.0
for members in groups.values where members.count > 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
)
}
}
Expand Down
Loading