mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
357 lines
9.5 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|