Meshtastic-Apple/Meshtastic/Views/Helpers/CompassView.swift
2026-04-16 06:57:29 +00:00

357 lines
9.5 KiB
Swift

//
// 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 waypointLongName: String?
let waypointShortName: String?
let color: Color
@ObservedObject private var locationsHandler = LocationsHandler.shared
// Haptic alignment tracking
private let alignmentTolerance: Double = 5.0
@State private var inAlignment = false
private let dialRadius: CGFloat = 140
// 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) {
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)
}
private func formatDistance(_ distance: CLLocationDistance) -> String {
let measurement = Measurement(value: distance, unit: UnitLength.meters)
let formatter = MeasurementFormatter()
formatter.unitOptions = .naturalScale
formatter.numberFormatter.maximumFractionDigits = 1
return formatter.string(from: measurement)
}
var body: some View {
NavigationStack {
ZStack {
VStack(spacing: 0) {
if waypointLongName != nil || waypointLocation != nil {
Spacer()
VStack(spacing: 4) {
Text(waypointLongName ?? "Waypoint")
.font(.largeTitle)
if let bearing = bearingToWaypoint() {
HStack(spacing: 4) {
Image(systemName: "location.north.fill")
.font(.title2)
.rotationEffect(.degrees(bearing))
Text("\(String(format: "%.0f°", bearing))")
.font(.title2)
}
.foregroundColor(.secondary)
}
}
.padding(.bottom, 8)
}
Spacer()
// Top fixed heading indicator triangle
Image(systemName: "triangle.fill")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.primary)
.rotationEffect(.degrees(180))
.padding(.bottom, 4)
// Rotating compass dial
ZStack {
// Outer bezel ring
Circle()
.stroke(Color.primary.opacity(0.2), lineWidth: 1.5)
.frame(width: dialRadius * 2 + 20, height: dialRadius * 2 + 20)
// Tick marks
ForEach(0..<360, id: \.self) { degree in
CompassTickMark(degree: Double(degree), radius: dialRadius)
}
// Cardinal and intercardinal labels
ForEach(CompassLabel.allLabels, id: \.degrees) { label in
CompassLabelView(label: label, radius: dialRadius - 28, heading: locationsHandler.heading)
}
// North triangle indicator at 0°
CompassNorthIndicator(radius: dialRadius + 2)
// Degree readout at center (counter-rotate to stay fixed)
ZStack {
let textColor = color.isLight() ? Color.black : Color.white
Circle()
.fill(color)
.overlay(
Circle().stroke(textColor.opacity(0.75), lineWidth: 4)
)
.frame(width: 172, height: 172)
VStack(spacing: 4) {
Text(headingText())
.font(.system(size: 42, weight: .light, design: .rounded))
.foregroundColor(textColor)
.monospacedDigit()
if let distance = distanceToWaypoint() {
Text(formatDistance(distance))
.font(.system(size: 18, weight: .semibold, design: .rounded))
.foregroundColor(textColor.opacity(0.9))
}
if waypointShortName != nil || waypointLocation != nil {
Text(waypointShortName ?? "WP")
.font(.title3)
.foregroundColor(textColor.opacity(0.75))
}
}
}
.rotationEffect(Angle(degrees: locationsHandler.heading))
// Waypoint bearing indicator
if let bearing = bearingToWaypoint() {
WaypointMarkerView(
bearing: bearing,
radius: dialRadius + 14,
color: color
)
.onChange(of: locationsHandler.heading) { _, _ in
checkAlignment(bearing: bearing, heading: locationsHandler.heading)
}
}
}
.frame(width: dialRadius * 2 + 40, height: dialRadius * 2 + 40)
.rotationEffect(Angle(degrees: -locationsHandler.heading))
Spacer()
// Bottom info
if let wp = waypointLocation {
VStack(spacing: 6) {
HStack(spacing: 4) {
Image(systemName: "mappin")
.font(.title2)
Text("\(String(format: "%.4f", wp.latitude)) \(String(format: "%.4f", wp.longitude))")
.font(.title3)
}
.foregroundColor(.secondary)
}
Spacer()
}
}
}
.statusBar(hidden: true)
.onAppear {
locationsHandler.startHeadingUpdates()
locationsHandler.startLocationUpdates()
}
.onDisappear {
locationsHandler.stopHeadingUpdates()
locationsHandler.stopLocationUpdates()
}
.navigationTitle("Compass")
}
}
private func headingText() -> String {
let h = Int(locationsHandler.heading.rounded()) % 360
return "\(h)°"
}
}
// MARK: - Compass Tick Mark
struct CompassTickMark: View {
let degree: Double
let radius: CGFloat
var body: some View {
let isCardinal = degree.truncatingRemainder(dividingBy: 90) == 0
let isMajor = degree.truncatingRemainder(dividingBy: 30) == 0
let isMinor = degree.truncatingRemainder(dividingBy: 10) == 0
let length: CGFloat = isCardinal ? 16 : (isMajor ? 12 : (isMinor ? 8 : 4))
let width: CGFloat = isCardinal ? 2.5 : (isMajor ? 1.5 : 1)
let tickColor: Color = isCardinal ? .primary : (isMajor ? .primary.opacity(0.7) : .primary.opacity(0.3))
// Only draw ticks at 2° intervals
if Int(degree) % 2 == 0 {
Capsule()
.fill(tickColor)
.frame(width: width, height: length)
.offset(y: -(radius - length / 2))
.rotationEffect(.degrees(degree))
}
}
}
// MARK: - North Indicator
struct CompassNorthIndicator: View {
let radius: CGFloat
var body: some View {
Triangle()
.fill(Color.orange)
.frame(width: 12, height: 10)
.offset(y: -(radius + 8))
}
}
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
path.closeSubpath()
return path
}
}
// MARK: - Compass Label Model & View
struct CompassLabel {
let degrees: Double
let text: String
let isCardinal: Bool
static let allLabels: [CompassLabel] = [
CompassLabel(degrees: 0, text: "N", isCardinal: true),
CompassLabel(degrees: 45, text: "NE", isCardinal: false),
CompassLabel(degrees: 90, text: "E", isCardinal: true),
CompassLabel(degrees: 135, text: "SE", isCardinal: false),
CompassLabel(degrees: 180, text: "S", isCardinal: true),
CompassLabel(degrees: 225, text: "SW", isCardinal: false),
CompassLabel(degrees: 270, text: "W", isCardinal: true),
CompassLabel(degrees: 315, text: "NW", isCardinal: false)
]
}
struct CompassLabelView: View {
let label: CompassLabel
let radius: CGFloat
let heading: Double
var body: some View {
Text(label.text)
.font(.system(size: label.isCardinal ? 18 : 13,
weight: label.isCardinal ? .bold : .medium))
.foregroundColor(label.degrees == 0 ? .orange : .primary)
.rotationEffect(.degrees(-label.degrees + heading))
.offset(y: -radius)
.rotationEffect(.degrees(label.degrees))
}
}
// MARK: - Waypoint Marker View
struct WaypointMarkerView: View {
let bearing: Double
let radius: CGFloat
let color: Color
var body: some View {
ZStack {
// Outer glow
Image(systemName: "arrowtriangle.up.fill")
.font(.system(size: 20, weight: .bold))
.foregroundColor(color.opacity(0.3))
.offset(y: -(radius + 4))
.rotationEffect(.degrees(bearing))
// Arrow
Image(systemName: "arrowtriangle.up.fill")
.font(.system(size: 16, weight: .bold))
.foregroundColor(color)
.offset(y: -(radius + 5))
.rotationEffect(.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: - Preview
struct CompassView_Previews: PreviewProvider {
static var previews: some View {
CompassView(
waypointLocation: CLLocationCoordinate2D(latitude: 37.3346, longitude: -122.0090),
waypointLongName: "Apple Park",
waypointShortName: "",
color: Color.orange
)
}
}