From a89f33b9f5288925a173da2432d311567c39268c Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Fri, 17 Nov 2023 18:35:31 -0800 Subject: [PATCH] Altitude graph for the node map --- Meshtastic.xcodeproj/project.pbxproj | 16 ++- Meshtastic/Extensions/Measurement.swift | 28 +++++ .../Nodes/Helpers/Map/NodeMapSwiftUI.swift | 41 +++++-- .../Helpers/Map/PositionAltitudeChart.swift | 100 ++++++++++++++++++ Meshtastic/Views/Nodes/MeshMap.swift | 10 +- 5 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 Meshtastic/Extensions/Measurement.swift create mode 100644 Meshtastic/Views/Nodes/Helpers/Map/PositionAltitudeChart.swift diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 559d2ff5..9581b975 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -19,6 +19,8 @@ DD13AA492AB73BF400BA0C98 /* PositionPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13AA482AB73BF400BA0C98 /* PositionPopover.swift */; }; DD1925B728CDA5A400720036 /* CannedMessagesConfigEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1925B628CDA5A400720036 /* CannedMessagesConfigEnums.swift */; }; DD1925B928CDA93900720036 /* SerialConfigEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1925B828CDA93900720036 /* SerialConfigEnums.swift */; }; + DD1933762B0835D500771CD5 /* PositionAltitudeChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1933752B0835D500771CD5 /* PositionAltitudeChart.swift */; }; + DD1933782B084F4200771CD5 /* Measurement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1933772B084F4200771CD5 /* Measurement.swift */; }; DD1BF2F92776FE2E008C8D2F /* UserMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */; }; DD2160AF28C5552500C17253 /* MQTTConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2160AE28C5552500C17253 /* MQTTConfig.swift */; }; DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */; }; @@ -224,6 +226,8 @@ DD14E72C2A80738F006E39BC /* MeshtasticDataModelV15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV15.xcdatamodel; sourceTree = ""; }; DD1925B628CDA5A400720036 /* CannedMessagesConfigEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CannedMessagesConfigEnums.swift; sourceTree = ""; }; DD1925B828CDA93900720036 /* SerialConfigEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConfigEnums.swift; sourceTree = ""; }; + DD1933752B0835D500771CD5 /* PositionAltitudeChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionAltitudeChart.swift; sourceTree = ""; }; + DD1933772B084F4200771CD5 /* Measurement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Measurement.swift; sourceTree = ""; }; DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMessageList.swift; sourceTree = ""; }; DD2160AE28C5552500C17253 /* MQTTConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTConfig.swift; sourceTree = ""; }; DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeripheralModel.swift; sourceTree = ""; }; @@ -636,6 +640,7 @@ DDB6CCFA2AAF805100945AF6 /* NodeMapSwiftUI.swift */, DD13AA482AB73BF400BA0C98 /* PositionPopover.swift */, DD46401F2AFF10F4002A5ECB /* WaypointForm.swift */, + DD1933752B0835D500771CD5 /* PositionAltitudeChart.swift */, ); path = Map; sourceTree = ""; @@ -856,6 +861,7 @@ DDDB443F29F79AB000EE2349 /* UserDefaults.swift */, DDB75A0E2A05920E006ED576 /* FileManager.swift */, DDB75A102A059258006ED576 /* Url.swift */, + DD1933772B084F4200771CD5 /* Measurement.swift */, ); path = Extensions; sourceTree = ""; @@ -1093,6 +1099,7 @@ DDDB444C29F8AAA600EE2349 /* Color.swift in Sources */, DDB8F4122A9EE5DD00230ECE /* UserList.swift in Sources */, DDB75A0F2A05920E006ED576 /* FileManager.swift in Sources */, + DD1933782B084F4200771CD5 /* Measurement.swift in Sources */, DD4F23CD28779A3C001D37CB /* EnvironmentMetricsLog.swift in Sources */, DD41A61529AB0035003C5A37 /* NodeWeatherForecast.swift in Sources */, DDB6ABD628AE742000384BA1 /* BluetoothConfig.swift in Sources */, @@ -1192,6 +1199,7 @@ DDB6ABE228B13FB500384BA1 /* PositionConfigEnums.swift in Sources */, DD5E520E298EE33B00D21B61 /* mqtt.pb.swift in Sources */, DD994B69295F88B60013760A /* IntervalEnums.swift in Sources */, + DD1933762B0835D500771CD5 /* PositionAltitudeChart.swift in Sources */, DD415828285859C4009B0E59 /* TelemetryConfig.swift in Sources */, DDDB443D29F6592F00EE2349 /* NetworkManager.swift in Sources */, DDB6CCFB2AAF805100945AF6 /* NodeMapSwiftUI.swift in Sources */, @@ -1427,7 +1435,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.2.11; + MARKETING_VERSION = 2.2.12; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1461,7 +1469,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.2.11; + MARKETING_VERSION = 2.2.12; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1583,7 +1591,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.11; + MARKETING_VERSION = 2.2.12; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1616,7 +1624,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.11; + MARKETING_VERSION = 2.2.12; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Meshtastic/Extensions/Measurement.swift b/Meshtastic/Extensions/Measurement.swift new file mode 100644 index 00000000..9fb3eec0 --- /dev/null +++ b/Meshtastic/Extensions/Measurement.swift @@ -0,0 +1,28 @@ +// +// Measurement.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 11/17/23. +// + +import Foundation +import Charts + +struct PlottableMeasurement { + var measurement: Measurement +} + +extension PlottableMeasurement: Plottable where UnitType == UnitLength { + var primitivePlottable: Double { + self.measurement.converted(to: .meters).value + } + + init?(primitivePlottable: Double) { + self.init( + measurement: Measurement( + value: primitivePlottable, + unit: .meters + ) + ) + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift index 113df743..863c24c5 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift @@ -33,6 +33,7 @@ struct NodeMapSwiftUI: View { @State var position = MapCameraPosition.automatic @State var scene: MKLookAroundScene? @State var isLookingAround = false + @State var isShowingAltitude = false @State var isEditingSettings = false @State var selectedPosition: PositionEntity? @State var showWaypoints = false @@ -75,10 +76,12 @@ struct NodeMapSwiftUI: View { } /// Convex Hull if showConvexHull { - let hull = lineCoords.getConvexHull() - MapPolygon(coordinates: hull) - .stroke(Color(nodeColor.darker()), lineWidth: 3) - .foregroundStyle(Color(nodeColor).opacity(0.4)) + if lineCoords.count > 0 { + let hull = lineCoords.getConvexHull() + MapPolygon(coordinates: hull) + .stroke(Color(nodeColor.darker()), lineWidth: 3) + .foregroundStyle(Color(nodeColor).opacity(0.4)) + } } /// Waypoint Annotations @@ -88,8 +91,6 @@ struct NodeMapSwiftUI: View { ZStack { CircleText(text: String(UnicodeScalar(Int(waypoint.icon)) ?? "📍"), color: Color.orange, circleSize: 35) .onTapGesture(coordinateSpace: .named("nodemap")) { location in - print("Tapped at \(location)") - let pinLocation = reader.convert(location, from: .local) selectedWaypoint = (selectedWaypoint == waypoint ? nil : waypoint) } } @@ -193,6 +194,14 @@ struct NodeMapSwiftUI: View { .padding(.horizontal, 20) } } + .overlay(alignment: .bottom) { + if !isLookingAround && isShowingAltitude { + PositionAltitudeChart(node: node) + .frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 20) + } + } .sheet(item: $selectedWaypoint) { selection in WaypointForm(waypoint: selection) .padding() @@ -273,9 +282,10 @@ struct NodeMapSwiftUI: View { /// Look Around Button if self.scene != nil { Button(action: { - withAnimation { - isLookingAround = !isLookingAround + if isShowingAltitude { + isShowingAltitude = false } + isLookingAround = !isLookingAround }) { Image(systemName: isLookingAround ? "binoculars.fill" : "binoculars") .padding(.vertical, 5) @@ -284,6 +294,21 @@ struct NodeMapSwiftUI: View { .foregroundColor(.accentColor) .buttonStyle(.borderedProminent) } + /// Altitude Button + if node.positions?.count ?? 0 > 1 { + Button(action: { + if isLookingAround { + isLookingAround = false + } + isShowingAltitude = !isShowingAltitude + }) { + Image(systemName: isShowingAltitude ? "mountain.2.fill" : "mountain.2") + .padding(.vertical, 5) + } + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + } #if targetEnvironment(macCatalyst) /// Hide non fuctional catalyst controls // MapZoomStepper(scope: mapScope) diff --git a/Meshtastic/Views/Nodes/Helpers/Map/PositionAltitudeChart.swift b/Meshtastic/Views/Nodes/Helpers/Map/PositionAltitudeChart.swift new file mode 100644 index 00000000..bf316e95 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Map/PositionAltitudeChart.swift @@ -0,0 +1,100 @@ +// +// PositionAltitudeChart.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 11/17/23. +// + +import SwiftUI +import Charts +#if canImport(MapKit) +import MapKit +#endif + + +@available(iOS 17.0, macOS 14.0, *) +struct PositionAltitudeChart: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var node: NodeInfoEntity + + @State private var lineWidth = 2.0 + @State private var interpolationMethod: ChartInterpolationMethod = .linear + @State private var chartColor: Color = .accentColor + @State private var showSymbols = true + + var body: some View { + let nodePositions = Array(node.positions!) as! [PositionEntity] + let data = nodePositions.map { PositionAltitude(time: $0.time ?? Date(), altitude: Measurement(value: Double($0.altitude), unit: .meters) ) } + HStack { + Chart(data, id: \.time) { + LineMark( + x: .value("Time", $0.time), + y: .value("Altitude", PlottableMeasurement(measurement: $0.altitude)) + ) + .accessibilityLabel($0.time.formatted(date: .abbreviated, time: .shortened)) + .accessibilityValue("\($0.altitude) ft high") + .lineStyle(StrokeStyle(lineWidth: lineWidth)) + .foregroundStyle(chartColor.gradient) + .interpolationMethod(interpolationMethod.mode) + .symbol(Circle().strokeBorder(lineWidth: lineWidth)) + .symbolSize(showSymbols ? 60 : 0) + } + .chartYAxis { + AxisMarks { value in + AxisGridLine() + AxisValueLabel(""" + \(value.as(PlottableMeasurement.self)! + .measurement + .converted(to: .meters), + format: .measurement( + width: .wide, + numberFormatStyle: .number.precision( + .fractionLength(0)) + ) + ) + """) + } + } + .chartXAxis(.visible) + } + .padding() + .background(Color(UIColor.secondarySystemBackground)) + .opacity(/*@START_MENU_TOKEN@*/0.8/*@END_MENU_TOKEN@*/) + } +} + +struct PositionAltitude { + let time: Date + var altitude: Measurement +} + +enum ChartInterpolationMethod: Identifiable, CaseIterable { + case linear + case monotone + case catmullRom + case cardinal + case stepStart + case stepCenter + case stepEnd + + var id: String { mode.description } + + var mode: InterpolationMethod { + switch self { + case .linear: + return .linear + case .monotone: + return .monotone + case .stepStart: + return .stepStart + case .stepCenter: + return .stepCenter + case .stepEnd: + return .stepEnd + case .catmullRom: + return .catmullRom + case .cardinal: + return .cardinal + } + } +} diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index 22341dd4..b7dce712 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -78,10 +78,12 @@ struct MeshMap: View { } /// Convex Hull if showConvexHull { - let hull = lineCoords.getConvexHull() - MapPolygon(coordinates: hull) - .stroke(.blue, lineWidth: 3) - .foregroundStyle(.indigo.opacity(0.4)) + if lineCoords.count > 0 { + let hull = lineCoords.getConvexHull() + MapPolygon(coordinates: hull) + .stroke(.blue, lineWidth: 3) + .foregroundStyle(.indigo.opacity(0.4)) + } } /// Position Annotations ForEach(Array(positions), id: \.id) { position in