Compass view (#1521)

* Added compass view

* Added Compass View

* Node colors in compass

* Update Muzi R1 Neo to actively supported

* Update PositionPopover.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Views/Helpers/CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Views/Helpers/CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Garth Vander Houwen 2025-12-15 11:15:54 -08:00 committed by GitHub
parent 865e5e950b
commit 8346fb8073
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 398 additions and 6 deletions

View file

@ -5135,6 +5135,12 @@
}
}
}
},
"Bearing: %@" : {
},
"Bearing: N/A" : {
},
"Biking" : {
"localizations" : {
@ -8085,6 +8091,9 @@
}
}
}
},
"Compass" : {
},
"Config" : {
"localizations" : {
@ -11995,6 +12004,9 @@
}
}
}
},
"Distance: %@" : {
},
"Documentation" : {
"localizations" : {
@ -24767,6 +24779,9 @@
}
}
}
},
"Open Compass" : {
},
"Open Settings" : {
"localizations" : {
@ -42306,4 +42321,4 @@
}
},
"version" : "1.1"
}
}

View file

@ -98,6 +98,7 @@
B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; };
BC10380F2DD4334400B00BFA /* AddContactIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */; };
BC6B45FF2CB2F98900723CEB /* SaveChannelSettingsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC6B45FE2CB2F98900723CEB /* SaveChannelSettingsIntent.swift */; };
BCA9A82C2EC802CF00166292 /* CompassView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA9A82B2EC802CF00166292 /* CompassView.swift */; };
BCB35B4F2E5FC42500B04F60 /* MessageNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB35B4E2E5FC41E00B04F60 /* MessageNodeIntent.swift */; };
BCB613812C67290800485544 /* SendWaypointIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613802C67290800485544 /* SendWaypointIntent.swift */; };
BCB613832C672A2600485544 /* MessageChannelIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613822C672A2600485544 /* MessageChannelIntent.swift */; };
@ -409,6 +410,7 @@
B3E905B02B71F7F300654D07 /* TextMessageField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMessageField.swift; sourceTree = "<group>"; };
BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactIntent.swift; sourceTree = "<group>"; };
BC6B45FE2CB2F98900723CEB /* SaveChannelSettingsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChannelSettingsIntent.swift; sourceTree = "<group>"; };
BCA9A82B2EC802CF00166292 /* CompassView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassView.swift; sourceTree = "<group>"; };
BCB35B4E2E5FC41E00B04F60 /* MessageNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageNodeIntent.swift; sourceTree = "<group>"; };
BCB613802C67290800485544 /* SendWaypointIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendWaypointIntent.swift; sourceTree = "<group>"; };
BCB613822C672A2600485544 /* MessageChannelIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageChannelIntent.swift; sourceTree = "<group>"; };
@ -1274,6 +1276,7 @@
8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */,
237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */,
23A1AFB62E42BD2500E46C96 /* RXTXIndicatorView.swift */,
BCA9A82B2EC802CF00166292 /* CompassView.swift */,
);
path = Helpers;
sourceTree = "<group>";
@ -1814,6 +1817,7 @@
233E99BE2D849D3200CC3A77 /* RadiationCompactWidget.swift in Sources */,
DDD94A502845C8F5004A87A0 /* DateTimeText.swift in Sources */,
DDB6ABE228B13FB500384BA1 /* PositionConfigEnums.swift in Sources */,
BCA9A82C2EC802CF00166292 /* CompassView.swift in Sources */,
DD994B69295F88B60013760A /* IntervalEnums.swift in Sources */,
23D316932E5618D2002FA4FB /* AsyncGate.swift in Sources */,
23FF00B62E323C75001DF095 /* AccessoryManager+Connect.swift in Sources */,

View file

@ -1,5 +1,5 @@
{
"originHash" : "fd71b247ba909b0eb360db5530e1068363839c5e169dea6f6a9974b2d98276f4",
"originHash" : "0fb5b226f8ca0b357ce7816ebac09d017cbe0ad253452876e5e2ff8e555c7124",
"pins" : [
{
"identity" : "cocoamqtt",

View file

@ -26,6 +26,8 @@ import OSLog
@Published var recordingStarted: Date?
@Published var distanceTraveled = 0.0
@Published var elevationGain = 0.0
@Published var heading: Double = 0.0 // Current heading in degrees
@Published var headingUpdatesStarted: Bool = false // Track heading updates state
@Published
var updatesStarted: Bool = UserDefaults.standard.bool(forKey: "liveUpdatesStarted") {
@ -131,6 +133,10 @@ import OSLog
self.manager.desiredAccuracy = kCLLocationAccuracyBest
// Set the distance filter to only receive updates when the device has moved a certain distance.
self.manager.distanceFilter = kCLDistanceFilterNone // Receive all updates initially
if CLLocationManager.headingAvailable() {
self.manager.headingFilter = 1 // Update heading when it changes by 1 degree
self.manager.headingOrientation = .portrait // Adjust based on device orientation
}
}
func startLocationUpdates() {
@ -178,6 +184,39 @@ import OSLog
// The Task completes implicitly here.
}
}
// New method to start heading updates
func startHeadingUpdates() {
guard CLLocationManager.headingAvailable() else {
Logger.services.warning("📍 [App] Heading updates not available on this device.")
return
}
guard manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse else {
Logger.services.warning("📍 [App] Cannot start heading updates: insufficient authorization status.")
return
}
Logger.services.info("📍 [App] Starting heading updates")
manager.startUpdatingHeading()
headingUpdatesStarted = true
}
// New method to stop heading updates
func stopHeadingUpdates() {
Logger.services.info("🛑 [App] Stopping heading updates")
manager.stopUpdatingHeading()
headingUpdatesStarted = false
}
// Implement the CLLocationManagerDelegate method for heading updates
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
// Update heading on the main thread
Task { @MainActor in
self.heading = newHeading.trueHeading >= 0 ? newHeading.trueHeading : newHeading.magneticHeading
}
}
/// Stops receiving live location updates.
func stopLocationUpdates() {
Logger.services.info("🛑 [App] Stopping location updates")

View file

@ -0,0 +1,295 @@
//
// CompassView.swift
// Meshtastic
//
// Created by Benjamin Faershtein on 11/14/25.
//
import SwiftUI
import CoreLocation
import UIKit
struct CompassView: View {
/// Single waypoint parameter
let waypointLocation: CLLocationCoordinate2D?
let waypointName: String?
let color: Color
@ObservedObject private var locationsHandler = LocationsHandler.shared
// Haptic alignment tracking
private let alignmentTolerance: Double = 5.0
@State private var inAlignment = false
// Compute bearing from user waypoint
private func bearingToWaypoint() -> Double? {
guard
let waypoint = waypointLocation,
let user = LocationsHandler.currentLocation
else { return nil }
return BearingCalculator.bearingBetween(
userLocation: user,
waypoint: waypoint
)
}
// Trigger a vibration if aligned with waypoint
private func checkAlignment(bearing: Double,heading: Double) {
// Compute minimal angular difference between heading and bearing in [0, 180]
let rawDiff = abs(heading - bearing).truncatingRemainder(dividingBy: 360)
let diff = min(rawDiff, 360 - rawDiff)
if diff <= alignmentTolerance {
if !inAlignment {
inAlignment = true
let generator = UIImpactFeedbackGenerator(style: .heavy)
generator.impactOccurred()
}
} else {
inAlignment = false
}
}
private func distanceToWaypoint() -> CLLocationDistance? {
guard
let waypoint = waypointLocation,
let user = LocationsHandler.currentLocation
else { return nil }
let userLocation = CLLocation(latitude: user.latitude, longitude: user.longitude)
let waypointLocation = CLLocation(latitude: waypoint.latitude, longitude: waypoint.longitude)
return userLocation.distance(from: waypointLocation)
}
// Format distance with localization
private func formatDistance(_ distance: CLLocationDistance) -> String {
let measurement = Measurement(value: distance, unit: UnitLength.meters)
let formatter = MeasurementFormatter()
formatter.unitOptions = .naturalScale
formatter.numberFormatter.maximumFractionDigits = 2
return formatter.string(from: measurement)
}
var body: some View {
NavigationStack {
VStack(spacing: 15) {
VStack(spacing: 8) {
Text(waypointName ?? "Waypoint")
.font(.title2)
.bold()
.foregroundColor(color)
if let wp = waypointLocation {
HStack{
Image(systemName: "mappin.and.ellipse")
Text("\(String(format: "%.4f", wp.latitude)), \(String(format: "%.4f", wp.longitude))")
.font(.subheadline)
}
if let distance = distanceToWaypoint() {
HStack{
Image(systemName: "lines.measurement.horizontal")
Text("Distance: \(formatDistance(distance))")
.font(.subheadline)
.fontWeight(.semibold)
}
}
HStack {
Image(systemName: "location.north")
if let bearing = bearingToWaypoint() {
Text("Bearing: \(String(format: "%.0f°", bearing))")
.font(.subheadline)
} else {
Text("Bearing: N/A")
.font(.subheadline)
}
}
}
}
.padding()
Capsule()
.frame(width: 5, height: 50)
ZStack {
// Cardinal/degree markers
ForEach(Marker.markers(), id: \.self) { marker in
CompassMarkerView(
marker: marker,
compassDegrees: -locationsHandler.heading
)
}
// Waypoint bearing indicator
if let bearing = bearingToWaypoint() {
WaypointMarkerView(
bearing: bearing,
compassDegrees: locationsHandler.heading,
color: color
)
// Move waypoint marker outside compass
.onChange(of: locationsHandler.heading) { _, _ in
checkAlignment(bearing: bearing,heading:locationsHandler.heading)
}
}
}
.frame(width: 300, height: 300)
.rotationEffect(Angle(degrees: -locationsHandler.heading))
.statusBar(hidden: true)
.onAppear {
locationsHandler.startHeadingUpdates()
locationsHandler.startLocationUpdates()
}
.onDisappear {
locationsHandler.stopHeadingUpdates()
locationsHandler.stopLocationUpdates()
}
.navigationTitle("Compass")
}
}
}
}
// MARK: - Waypoint Marker View
struct WaypointMarkerView: View {
let bearing: Double
let compassDegrees: Double
let color: Color
var body: some View {
Circle()
.frame(width: 20, height: 20)
.foregroundColor(color)
.offset(y: -170)
.rotationEffect(Angle(degrees: bearing))
}
}
// MARK: - Bearing Calculator
struct BearingCalculator {
static func bearingBetween(
userLocation: CLLocationCoordinate2D,
waypoint: CLLocationCoordinate2D
) -> Double {
let lat1 = userLocation.latitude * .pi / 180
let lon1 = userLocation.longitude * .pi / 180
let lat2 = waypoint.latitude * .pi / 180
let lon2 = waypoint.longitude * .pi / 180
let dLon = lon2 - lon1
let y = sin(dLon) * cos(lat2)
let x = cos(lat1) * sin(lat2)
- sin(lat1) * cos(lat2) * cos(dLon)
var bearing = atan2(y, x) * 180 / .pi
if bearing < 0 { bearing += 360 }
return bearing
}
}
// MARK: - Marker Model
struct Marker: Hashable {
let degrees: Double
let label: String
init(degrees: Double, label: String = "") {
self.degrees = degrees
self.label = label
}
func degreeText() -> String {
return String(format: "%.0f", self.degrees)
}
static func markers() -> [Marker] {
return [
Marker(degrees: 0, label: "N"),
Marker(degrees: 30),
Marker(degrees: 60),
Marker(degrees: 90, label: "E"),
Marker(degrees: 120),
Marker(degrees: 150),
Marker(degrees: 180, label: "S"),
Marker(degrees: 210),
Marker(degrees: 240),
Marker(degrees: 270, label: "W"),
Marker(degrees: 300),
Marker(degrees: 330)
]
}
}
// MARK: - Compass Marker View
struct CompassMarkerView: View {
let marker: Marker
let compassDegrees: Double
var body: some View {
VStack {
Text(marker.degreeText())
.fontWeight(.light)
.rotationEffect(textAngle())
Capsule()
.frame(width: capsuleWidth(), height: capsuleHeight())
.foregroundColor(capsuleColor())
Text(marker.label)
.fontWeight(.bold)
.rotationEffect(textAngle())
.padding(.bottom, 180)
}
.rotationEffect(Angle(degrees: marker.degrees))
}
private func capsuleWidth() -> CGFloat {
marker.degrees == 0 ? 7 : 3
}
private func capsuleHeight() -> CGFloat {
marker.degrees == 0 ? 45 : 30
}
private func capsuleColor() -> Color {
marker.degrees == 0 ? .red : .gray
}
private func textAngle() -> Angle {
Angle(degrees: -compassDegrees - marker.degrees)
}
}
// MARK: - Preview
struct CompassView_Previews: PreviewProvider {
static var previews: some View {
CompassView(
waypointLocation: CLLocationCoordinate2D(latitude: 37.3346, longitude: -122.0090),
waypointName: "Apple Park",
color: Color.orange
)
}
}

View file

@ -19,6 +19,9 @@ struct PositionPopover: View {
var position: PositionEntity
var popover: Bool = true
let distanceFormatter = MKDistanceFormatter()
@State private var detentSelection: PresentationDetent = .fraction(0.65)
@State private var navigateToCompass = false
var body: some View {
// Node Color from node.num
@ -42,6 +45,19 @@ struct PositionPopover: View {
Divider()
HStack(alignment: .center) {
VStack(alignment: .leading) {
Button {
detentSelection = .large
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
navigateToCompass = true
}
} label: {
HStack {
Image(systemName: "safari")
Text("Open Compass")
}
}
.padding(.bottom, 5)
/// Time
Label {
if idiom != .phone {
@ -131,6 +147,7 @@ struct PositionPopover: View {
}
.padding(.bottom, 5)
}
/// Heading
let degrees = Angle.degrees(Double(position.heading))
Label {
@ -234,10 +251,17 @@ struct PositionPopover: View {
#endif
}
}
.presentationDetents([.fraction(0.65), .large], selection: $detentSelection)
.presentationContentInteraction(.scrolls)
.presentationDragIndicator(.visible)
.presentationBackgroundInteraction(.enabled(upThrough: .large))
.navigationDestination(isPresented: $navigateToCompass) {
CompassView(
waypointLocation: position.coordinate,
waypointName: position.nodePosition?.user?.longName ?? "Unknown node",
color: (position.nodePosition?.user?.num != nil && position.nodePosition?.user?.num != 0) ? Color(UIColor(hex: UInt32(position.nodePosition!.user!.num))) : .orange
)
}
}
.presentationDetents([.fraction(0.65), .large])
.presentationContentInteraction(.scrolls)
.presentationDragIndicator(.visible)
.presentationBackgroundInteraction(.enabled(upThrough: .large))
}
}

View file

@ -27,6 +27,7 @@ struct NodeDetail: View {
var connectedNode: NodeInfoEntity?
@ObservedObject var node: NodeInfoEntity
@State private var environmentSectionHeight: CGFloat = 0
@State var showingCompassSheet = false
var body: some View {
NavigationStack {
@ -473,6 +474,17 @@ struct NodeDetail: View {
)
}
if node.hasPositions {
#if !targetEnvironment(macCatalyst)
Button {
showingCompassSheet = true
} label: {
Label {
Text("Open Compass")
} icon: {
Image(systemName: "safari")
}
}
#endif
NavigateToButton(node: node)
}
IgnoreNodeButton(
@ -559,6 +571,9 @@ struct NodeDetail: View {
}
}
}
.sheet(isPresented: $showingCompassSheet) {
CompassView(waypointLocation: node.latestPosition?.nodeCoordinate ?? nil, waypointName: node.user?.longName ?? nil, color: Color(UIColor(hex: UInt32(node.num))))
}
.onAppear {
scrollView.scrollTo("topOfList", anchor: .top)
}