From 045bd7124af1a58bfa68efadf7fd16abce2951d7 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Mon, 22 Jul 2024 17:40:06 -0700 Subject: [PATCH 01/14] Filter user predicate --- Meshtastic/Views/Messages/UserList.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index 5c7214fe..226d2ae9 100644 --- a/Meshtastic/Views/Messages/UserList.swift +++ b/Meshtastic/Views/Messages/UserList.swift @@ -33,7 +33,9 @@ struct UserList: View { sortDescriptors: [NSSortDescriptor(key: "lastMessage", ascending: false), NSSortDescriptor(key: "userNode.favorite", ascending: false), NSSortDescriptor(key: "longName", ascending: true)], - animation: .default + predicate: NSPredicate( + format: "NOT (userNode.viaMqtt == YES AND userNode.hopsAway > 0)" + ), animation: .default ) private var users: FetchedResults @@ -244,15 +246,18 @@ struct UserList: View { let textSearchPredicate = NSCompoundPredicate(type: .or, subpredicates: searchPredicates) /// Create an array of predicates to hold our AND predicates var predicates: [NSPredicate] = [] - /// Mqtt + /// Mqtt and lora if !(viaLora && viaMqtt) { if viaLora { let loraPredicate = NSPredicate(format: "userNode.viaMqtt == NO") predicates.append(loraPredicate) } else { - let mqttPredicate = NSPredicate(format: "userNode.viaMqtt == YES") + let mqttPredicate = NSPredicate(format: "userNode.viaMqtt == YES AND userNode.hopsAway == 0") predicates.append(mqttPredicate) } + } else { + let mqttPredicate = NSPredicate(format: "NOT (userNode.viaMqtt == YES AND userNode.hopsAway > 0)") + predicates.append(mqttPredicate) } /// Roles if roleFilter && deviceRoles.count > 0 { From 60d6f678720f783f2b6b346a810c8e13c744afac Mon Sep 17 00:00:00 2001 From: Brent Petit Date: Sat, 2 Nov 2024 10:22:19 -0500 Subject: [PATCH 02/14] Update direct traceroute handling to use two-way logic --- Localizable.xcstrings | 12 +- Meshtastic/Helpers/BLEManager.swift | 172 ++++++++++----------- Meshtastic/Views/Nodes/TraceRouteLog.swift | 12 +- 3 files changed, 84 insertions(+), 112 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 2881b290..ad9a5eb5 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -14556,7 +14556,7 @@ } }, "mesh.log.traceroute.received.direct %@" : { - "extractionState" : "migrated", + "extractionState" : "manual", "localizations" : { "de" : { "stringUnit" : { @@ -23077,16 +23077,6 @@ }, "Trace Route Log" : { - }, - "Trace route received directly by %@ with a SNR of %@ dB" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Trace route received directly by %1$@ with a SNR of %2$@ dB" - } - } - } }, "Trace Route Sent" : { diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index bb707885..3d30708f 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -834,45 +834,92 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate if let routingMessage = try? RouteDiscovery(serializedBytes: decodedInfo.packet.decoded.payload) { let traceRoute = getTraceRoute(id: Int64(decodedInfo.packet.decoded.requestID), context: context) traceRoute?.response = true - if routingMessage.route.count == 0 { - // Routing messages snr values are snr * 4 stored as an int - // If a traceroute snr value is unknown this field will contain INT8_MIN or -128 - // After converting to a float here, -32 is our unknown value. - let snr = routingMessage.snrBack.count > 0 ? (Float(routingMessage.snrBack[0]) / 4) : -32 - traceRoute?.snr = snr - let logString = String.localizedStringWithFormat("mesh.log.traceroute.received.direct %@".localized, String(snr)) - MeshLogger.log("🪧 \(logString)") - } else { - guard let connectedNode = getNodeInfo(id: Int64(connectedPeripheral.num), context: context) else { - return + guard let connectedNode = getNodeInfo(id: Int64(connectedPeripheral.num), context: context) else { + return + } + var hopNodes: [TraceRouteHopEntity] = [] + let connectedHop = TraceRouteHopEntity(context: context) + connectedHop.time = Date() + connectedHop.num = connectedPeripheral.num + connectedHop.name = connectedNode.user?.longName ?? "???" + // If nil, set to unknown, INT8_MIN (-128) then divide by 4 + connectedHop.snr = Float(routingMessage.snrBack.last ?? -128) / 4 + if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! { + connectedHop.altitude = mostRecent.altitude + connectedHop.latitudeI = mostRecent.latitudeI + connectedHop.longitudeI = mostRecent.longitudeI + traceRoute?.hasPositions = true + } + var routeString = "\(connectedNode.user?.longName ?? "???") --> " + hopNodes.append(connectedHop) + traceRoute?.hopsTowards = Int32(routingMessage.route.count) + for (index, node) in routingMessage.route.enumerated() { + var hopNode = getNodeInfo(id: Int64(node), context: context) + if hopNode == nil && hopNode?.num ?? 0 > 0 && node != 4294967295 { + hopNode = createNodeInfo(num: Int64(node), context: context) } - var hopNodes: [TraceRouteHopEntity] = [] - let connectedHop = TraceRouteHopEntity(context: context) - connectedHop.time = Date() - connectedHop.num = connectedPeripheral.num - connectedHop.name = connectedNode.user?.longName ?? "???" - // If nil, set to unknown, INT8_MIN (-128) then divide by 4 - connectedHop.snr = Float(routingMessage.snrBack.last ?? -128) / 4 - if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! { - connectedHop.altitude = mostRecent.altitude - connectedHop.latitudeI = mostRecent.latitudeI - connectedHop.longitudeI = mostRecent.longitudeI - traceRoute?.hasPositions = true + let traceRouteHop = TraceRouteHopEntity(context: context) + traceRouteHop.time = Date() + if routingMessage.snrTowards.count >= index + 1 { + traceRouteHop.snr = Float(routingMessage.snrTowards[index]) / 4 + } else { + // If no snr in route, set unknown + traceRouteHop.snr = -32 } - var routeString = "\(connectedNode.user?.longName ?? "???") --> " - hopNodes.append(connectedHop) - traceRoute?.hopsTowards = Int32(routingMessage.route.count) - for (index, node) in routingMessage.route.enumerated() { + if let hn = hopNode, hn.hasPositions { + if let mostRecent = hn.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! { + traceRouteHop.altitude = mostRecent.altitude + traceRouteHop.latitudeI = mostRecent.latitudeI + traceRouteHop.longitudeI = mostRecent.longitudeI + traceRoute?.hasPositions = true + } + } + traceRouteHop.num = hopNode?.num ?? 0 + if hopNode != nil { + if decodedInfo.packet.rxTime > 0 { + hopNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(decodedInfo.packet.rxTime))) + } + } + hopNodes.append(traceRouteHop) + + let hopName = hopNode?.user?.longName ?? (node == 4294967295 ? "Repeater" : String(hopNode?.num.toHex() ?? "unknown".localized)) + let mqttLabel = hopNode?.viaMqtt ?? false ? "MQTT " : "" + let snrLabel = (traceRouteHop.snr != -32) ? String(traceRouteHop.snr) : "unknown ".localized + routeString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> " + } + let destinationHop = TraceRouteHopEntity(context: context) + destinationHop.name = traceRoute?.node?.user?.longName ?? "unknown".localized + destinationHop.time = Date() + // If nil, set to unknown, INT8_MIN (-128) then divide by 4 + destinationHop.snr = Float(routingMessage.snrTowards.last ?? -128) / 4 + destinationHop.num = traceRoute?.node?.num ?? 0 + if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! { + destinationHop.altitude = mostRecent.altitude + destinationHop.latitudeI = mostRecent.latitudeI + destinationHop.longitudeI = mostRecent.longitudeI + traceRoute?.hasPositions = true + } + hopNodes.append(destinationHop) + /// Add the destination node to the end of the route towards string and the beginning of the route back string + routeString += "\(traceRoute?.node?.user?.longName ?? "unknown".localized) \((traceRoute?.node?.num ?? 0).toHex()) (\(destinationHop.snr != -32 ? String(destinationHop.snr) : "unknown ".localized)dB)" + traceRoute?.routeText = routeString + + traceRoute?.hopsBack = Int32(routingMessage.routeBack.count) + // Only if hopStart is set and there is an SNR entry + if decodedInfo.packet.hopStart > 0 && routingMessage.snrBack.count > 0 { + var routeBackString = "\(traceRoute?.node?.user?.longName ?? "unknown".localized) \((traceRoute?.node?.num ?? 0).toHex()) --> " + for (index, node) in routingMessage.routeBack.enumerated() { var hopNode = getNodeInfo(id: Int64(node), context: context) if hopNode == nil && hopNode?.num ?? 0 > 0 && node != 4294967295 { hopNode = createNodeInfo(num: Int64(node), context: context) } let traceRouteHop = TraceRouteHopEntity(context: context) traceRouteHop.time = Date() - if routingMessage.snrTowards.count >= index + 1 { - traceRouteHop.snr = Float(routingMessage.snrTowards[index]) / 4 + traceRouteHop.back = true + if routingMessage.snrBack.count >= index + 1 { + traceRouteHop.snr = Float(routingMessage.snrBack[index]) / 4 } else { - // If no snr in route, set unknown + // If no snr in route, set to unknown traceRouteHop.snr = -32 } if let hn = hopNode, hn.hasPositions { @@ -894,69 +941,12 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let hopName = hopNode?.user?.longName ?? (node == 4294967295 ? "Repeater" : String(hopNode?.num.toHex() ?? "unknown".localized)) let mqttLabel = hopNode?.viaMqtt ?? false ? "MQTT " : "" let snrLabel = (traceRouteHop.snr != -32) ? String(traceRouteHop.snr) : "unknown ".localized - routeString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> " + routeBackString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> " } - let destinationHop = TraceRouteHopEntity(context: context) - destinationHop.name = traceRoute?.node?.user?.longName ?? "unknown".localized - destinationHop.time = Date() // If nil, set to unknown, INT8_MIN (-128) then divide by 4 - destinationHop.snr = Float(routingMessage.snrTowards.last ?? -128) / 4 - destinationHop.num = traceRoute?.node?.num ?? 0 - if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! { - destinationHop.altitude = mostRecent.altitude - destinationHop.latitudeI = mostRecent.latitudeI - destinationHop.longitudeI = mostRecent.longitudeI - traceRoute?.hasPositions = true - } - hopNodes.append(destinationHop) - /// Add the destination node to the end of the route towards string and the beginning of the route back string - routeString += "\(traceRoute?.node?.user?.longName ?? "unknown".localized) \((traceRoute?.node?.num ?? 0).toHex()) (\(destinationHop.snr != -32 ? String(destinationHop.snr) : "unknown ".localized)dB)" - traceRoute?.routeText = routeString - - traceRoute?.hopsBack = Int32(routingMessage.routeBack.count) - // Only if hopStart is set and there is an SNR entry - if decodedInfo.packet.hopStart > 0 && routingMessage.snrBack.count > 0 { - var routeBackString = "\(traceRoute?.node?.user?.longName ?? "unknown".localized) \((traceRoute?.node?.num ?? 0).toHex()) --> " - for (index, node) in routingMessage.routeBack.enumerated() { - var hopNode = getNodeInfo(id: Int64(node), context: context) - if hopNode == nil && hopNode?.num ?? 0 > 0 && node != 4294967295 { - hopNode = createNodeInfo(num: Int64(node), context: context) - } - let traceRouteHop = TraceRouteHopEntity(context: context) - traceRouteHop.time = Date() - traceRouteHop.back = true - if routingMessage.snrBack.count >= index + 1 { - traceRouteHop.snr = Float(routingMessage.snrBack[index]) / 4 - } else { - // If no snr in route, set to unknown - traceRouteHop.snr = -32 - } - if let hn = hopNode, hn.hasPositions { - if let mostRecent = hn.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! { - traceRouteHop.altitude = mostRecent.altitude - traceRouteHop.latitudeI = mostRecent.latitudeI - traceRouteHop.longitudeI = mostRecent.longitudeI - traceRoute?.hasPositions = true - } - } - traceRouteHop.num = hopNode?.num ?? 0 - if hopNode != nil { - if decodedInfo.packet.rxTime > 0 { - hopNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(decodedInfo.packet.rxTime))) - } - } - hopNodes.append(traceRouteHop) - - let hopName = hopNode?.user?.longName ?? (node == 4294967295 ? "Repeater" : String(hopNode?.num.toHex() ?? "unknown".localized)) - let mqttLabel = hopNode?.viaMqtt ?? false ? "MQTT " : "" - let snrLabel = (traceRouteHop.snr != -32) ? String(traceRouteHop.snr) : "unknown ".localized - routeBackString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> " - } - // If nil, set to unknown, INT8_MIN (-128) then divide by 4 - let snrBackLast = Float(routingMessage.snrBack.last ?? -128) / 4 - routeBackString += "\(connectedNode.user?.longName ?? String(connectedNode.num.toHex())) (\(snrBackLast != -32 ? String(snrBackLast) : "unknown ".localized)dB)" - traceRoute?.routeBackText = routeBackString - } + let snrBackLast = Float(routingMessage.snrBack.last ?? -128) / 4 + routeBackString += "\(connectedNode.user?.longName ?? String(connectedNode.num.toHex())) (\(snrBackLast != -32 ? String(snrBackLast) : "unknown ".localized)dB)" + traceRoute?.routeBackText = routeBackString traceRoute?.hops = NSOrderedSet(array: hopNodes) traceRoute?.time = Date() do { diff --git a/Meshtastic/Views/Nodes/TraceRouteLog.swift b/Meshtastic/Views/Nodes/TraceRouteLog.swift index e6e72841..33d4ffe4 100644 --- a/Meshtastic/Views/Nodes/TraceRouteLog.swift +++ b/Meshtastic/Views/Nodes/TraceRouteLog.swift @@ -54,7 +54,7 @@ struct TraceRouteLog: View { .font(.caption) } } icon: { - Image(systemName: route.response ? (route.hops?.count == 0 && route.response ? "person.line.dotted.person" : "point.3.connected.trianglepath.dotted") : "person.slash") + Image(systemName: route.response ? (route.hopsTowards == 0 && route.response ? "person.line.dotted.person" : "point.3.connected.trianglepath.dotted") : "person.slash") .symbolRenderingMode(.hierarchical) } .swipeActions { @@ -76,15 +76,7 @@ struct TraceRouteLog: View { Divider() ScrollView { if selectedRoute != nil { - if selectedRoute?.response ?? false && selectedRoute?.hopsTowards ?? 0 == 0 { - Label { - Text("Trace route received directly by \(selectedRoute?.node?.user?.longName ?? "unknown".localized) with a SNR of \(String(format: "%.2f", selectedRoute?.snr ?? 0.0)) dB") - } icon: { - Image(systemName: "signpost.right.and.left") - .symbolRenderingMode(.hierarchical) - } - .font(.title3) - } else if selectedRoute?.response ?? false && selectedRoute?.hopsTowards ?? 0 > 0 { + if selectedRoute?.response ?? false && selectedRoute?.hopsTowards ?? 0 >= 0 { Label { Text("Route: \(selectedRoute?.routeText ?? "unknown".localized)") } icon: { From 7ef92bb29b9332192a7924ad7fa88eb191c882cd Mon Sep 17 00:00:00 2001 From: Jake-B Date: Tue, 10 Dec 2024 18:43:46 -0500 Subject: [PATCH 03/14] Initial implementation of configurable Environment Metrics visualization --- Localizable.xcstrings | 14 +- Meshtastic.xcodeproj/project.pbxproj | 32 +++ .../MetricColumnConfigurationEntry.swift | 80 +++++++ .../MetricsColumnConfiguration.swift | 39 ++++ .../Views/Nodes/EnvironmentMetricsLog.swift | 94 +++----- .../EnvironmentMetricsColumnDefaults.swift | 207 ++++++++++++++++++ .../Metrics Columns/MetricsColumnDetail.swift | 54 +++++ 7 files changed, 452 insertions(+), 68 deletions(-) create mode 100644 Meshtastic/Model/Metrics Columns/MetricColumnConfigurationEntry.swift create mode 100644 Meshtastic/Model/Metrics Columns/MetricsColumnConfiguration.swift create mode 100644 Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentMetricsColumnDefaults.swift create mode 100644 Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 2881b290..43ac3828 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -3241,6 +3241,9 @@ }, "Channels being added from the QR code did not save. When adding channels the names must be unique." : { + }, + "Chart" : { + }, "CHG" : { @@ -3507,6 +3510,9 @@ } } } + }, + "Config" : { + }, "config.module.paxcounter.enabled.description" : { "localizations" : { @@ -15132,6 +15138,9 @@ } } } + }, + "Metric" : { + }, "Minimum Distance" : { "localizations" : { @@ -21535,6 +21544,9 @@ }, "Supported I2C Connected sensors will be detected automatically, sensors are BMP280, BME280, BME680, MCP9808, INA219, INA260, LPS22 and SHTC3." : { + }, + "Table" : { + }, "tapback" : { "localizations" : { @@ -24065,4 +24077,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 6ea81c15..90bb4a30 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 231B3F212D087A4C0069A07D /* MetricColumnConfigurationEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F202D087A4C0069A07D /* MetricColumnConfigurationEntry.swift */; }; + 231B3F222D087A4C0069A07D /* MetricsColumnConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnConfiguration.swift */; }; + 231B3F252D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F242D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift */; }; + 231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */; }; 251926852C3BA97800249DF5 /* FavoriteNodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */; }; 251926872C3BAE2200249DF5 /* NodeAlertsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */; }; 2519268A2C3BB1B200249DF5 /* ExchangePositionsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */; }; @@ -258,6 +262,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 231B3F1F2D087A4C0069A07D /* MetricsColumnConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnConfiguration.swift; sourceTree = ""; }; + 231B3F202D087A4C0069A07D /* MetricColumnConfigurationEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricColumnConfigurationEntry.swift; sourceTree = ""; }; + 231B3F242D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentMetricsColumnDefaults.swift; sourceTree = ""; }; + 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = ""; }; 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteNodeButton.swift; sourceTree = ""; }; 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeAlertsButton.swift; sourceTree = ""; }; 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangePositionsButton.swift; sourceTree = ""; }; @@ -552,6 +560,24 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 231B3F1E2D0879BC0069A07D /* Metrics Columns */ = { + isa = PBXGroup; + children = ( + 231B3F1F2D087A4C0069A07D /* MetricsColumnConfiguration.swift */, + 231B3F202D087A4C0069A07D /* MetricColumnConfigurationEntry.swift */, + ); + path = "Metrics Columns"; + sourceTree = ""; + }; + 231B3F232D087C020069A07D /* Metrics Columns */ = { + isa = PBXGroup; + children = ( + 231B3F242D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift */, + 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */, + ); + path = "Metrics Columns"; + sourceTree = ""; + }; 251926882C3BAF2E00249DF5 /* Actions */ = { isa = PBXGroup; children = ( @@ -931,6 +957,7 @@ DDC2E18826CE24EE0042C5E4 /* Model */ = { isa = PBXGroup; children = ( + 231B3F1E2D0879BC0069A07D /* Metrics Columns */, DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */, ); path = Model; @@ -1032,6 +1059,7 @@ DDDB26402AABEF7B003AFCB7 /* Helpers */ = { isa = PBXGroup; children = ( + 231B3F232D087C020069A07D /* Metrics Columns */, DDAD49EB2AFAE82500B4425D /* Map */, DDDB26432AAC0206003AFCB7 /* NodeDetail.swift */, DDDB26452AACC0B7003AFCB7 /* NodeInfoItem.swift */, @@ -1306,6 +1334,7 @@ DD5D0A9C2931B9F200F7EA61 /* EthernetModes.swift in Sources */, 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */, DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */, + 231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */, DDC2E1A726CEB3400042C5E4 /* LocationHelper.swift in Sources */, DD77093D2AA1AFA3007A8BF0 /* ChannelTips.swift in Sources */, 6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */, @@ -1335,6 +1364,7 @@ DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */, DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */, DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */, + 231B3F252D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift in Sources */, 25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */, DD354FD92BD96A0B0061A25F /* IAQScale.swift in Sources */, DDDB445429F8AD1600EE2349 /* Data.swift in Sources */, @@ -1419,6 +1449,8 @@ DD3CC24C2C498D6C001BD3A2 /* BatteryCompact.swift in Sources */, BCB613812C67290800485544 /* SendWaypointIntent.swift in Sources */, DD1B8F402B35E2F10022AABC /* GPSStatus.swift in Sources */, + 231B3F212D087A4C0069A07D /* MetricColumnConfigurationEntry.swift in Sources */, + 231B3F222D087A4C0069A07D /* MetricsColumnConfiguration.swift in Sources */, DD8ED9C52898D51F00B3B0AB /* NetworkConfig.swift in Sources */, DDC3B274283F411B00AC321C /* LastHeardText.swift in Sources */, DDDE5A1029AFE69700490C6C /* MeshActivityAttributes.swift in Sources */, diff --git a/Meshtastic/Model/Metrics Columns/MetricColumnConfigurationEntry.swift b/Meshtastic/Model/Metrics Columns/MetricColumnConfigurationEntry.swift new file mode 100644 index 00000000..7ad10089 --- /dev/null +++ b/Meshtastic/Model/Metrics Columns/MetricColumnConfigurationEntry.swift @@ -0,0 +1,80 @@ +// +// SeriesConfigurationEntry.swift +// Meshtastic +// +// Created by Jake Bordens on 12/7/24. +// + +import SwiftUI +import Charts +import OSLog + +struct MetricVisualizationType: OptionSet { + let rawValue: Int + + static let chart = MetricVisualizationType(rawValue: 1 << 0) + static let table = MetricVisualizationType(rawValue: 1 << 1) + + static let all: MetricVisualizationType = [.chart, .table] +} +class MetricsColumnConfigurationEntry: ObservableObject { + let attribute: String // CoreData Attribute Name on TelemetryEntity + let availability: MetricVisualizationType // Determine where this attribute can appear + let columnName: String // Heading for wider tables + let abbreviatedColumnName: String // Heading for space-constrained tables + let minWidth: CGFloat? // Minimum grid width for this column + let maxWidth: CGFloat? // Maximum grid width for this column + let spacing: CGFloat // Recommended spacing, may be overridden + var showInTable: Bool // Should this column appear in the table + var showInChart: Bool // Should this column appear in the chart + let tableBodyClosure: (MetricsColumnConfigurationEntry, TelemetryEntity) -> AnyView // Closure to render the view + let chartBodyClosure: (MetricsColumnConfigurationEntry, TelemetryEntity) -> AnyChartContent // Closure to render the chart + + init(attribute: String, keyPath: KeyPath, + availability: MetricVisualizationType = .all, + columnName: String, abbreviatedColumnName: String, + minWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, spacing: CGFloat = 0.1, + showInTable: Bool = true, showInChart: Bool = false, + @ViewBuilder tableBody: @escaping (MetricsColumnConfigurationEntry, Value) -> TableContent, + @ChartContentBuilder chartBody: @escaping (MetricsColumnConfigurationEntry, Date, Value) -> ChartAxes) { + self.attribute = attribute + self.availability = availability + self.columnName = columnName + self.abbreviatedColumnName = abbreviatedColumnName + self.minWidth = minWidth + self.maxWidth = maxWidth + self.spacing = spacing + self.showInTable = showInTable + self.showInChart = showInChart + self.tableBodyClosure = { config, entity in AnyView(tableBody(config, entity[keyPath: keyPath])) } + self.chartBodyClosure = { config, entity in AnyChartContent(chartBody(config, entity.time!, entity[keyPath: keyPath])) } + } + + var gridItemSize: GridItem.Size { + if let minWidth, let maxWidth { + return .flexible(minimum: minWidth, maximum: maxWidth) + } + return .flexible() + } + + func tableBody(_ te: TelemetryEntity) -> AnyView { + return tableBodyClosure(self, te) + } + + func chartBody(_ te: TelemetryEntity) -> AnyChartContent { + return chartBodyClosure(self, te) + } + +} + +extension MetricsColumnConfigurationEntry: Identifiable, Hashable { + var id: String { self.attribute } + + static func == (lhs: MetricsColumnConfigurationEntry, rhs: MetricsColumnConfigurationEntry) -> Bool { + lhs.attribute == rhs.attribute + } + + func hash(into hasher: inout Hasher) { + hasher.combine(attribute) + } +} diff --git a/Meshtastic/Model/Metrics Columns/MetricsColumnConfiguration.swift b/Meshtastic/Model/Metrics Columns/MetricsColumnConfiguration.swift new file mode 100644 index 00000000..c61116d9 --- /dev/null +++ b/Meshtastic/Model/Metrics Columns/MetricsColumnConfiguration.swift @@ -0,0 +1,39 @@ +// +// SeriesConfiguration.swift +// Meshtastic +// +// Created by Jake Bordens on 12/7/24. +// +import SwiftUI + +class MetricsColumnConfiguration: ObservableObject { + + @Published var columns: [MetricsColumnConfigurationEntry] + + init(columns: [MetricsColumnConfigurationEntry]) { + self.columns = columns + } + + var activeTableColumns: [MetricsColumnConfigurationEntry] { + return columns.filter { $0.showInTable && $0.availability.contains(.table)} + } + + var activeChartColumns: [MetricsColumnConfigurationEntry] { + return columns.filter { $0.showInChart } + } + + var gridItems: [GridItem] { + var returnValues: [GridItem] = [] + let columnsInChart = self.activeTableColumns + for i in 0.. 0 { GroupBox(label: Label("\(environmentMetrics.count) Readings Total", systemImage: "chart.xyaxis.line")) { - Chart { + Chart(columnConfiguration.activeChartColumns, id: \.columnName) { series in ForEach(chartData, id: \.time) { dataPoint in - AreaMark( - x: .value("Time", dataPoint.time!), - y: .value("Temperature", dataPoint.temperature.localeTemperature()), - stacking: .unstacked - ) - .interpolationMethod(.cardinal) - .foregroundStyle( - .linearGradient( - colors: [.blue, .yellow, .orange, .red, .red], - startPoint: .bottom, endPoint: .top - ) - .opacity(0.6) - ) - .alignsMarkStylesWithPlotArea() - .accessibilityHidden(true) - LineMark( - x: .value("Time", dataPoint.time!), - y: .value("Temperature", dataPoint.temperature.localeTemperature()) - ) - .interpolationMethod(.cardinal) - .foregroundStyle( - .linearGradient( - colors: [.blue, .yellow, .orange, .red, .red], - startPoint: .bottom, endPoint: .top - ) - ) - .lineStyle(StrokeStyle(lineWidth: 4)) - .alignsMarkStylesWithPlotArea() + series.chartBody(dataPoint) } } .chartXAxis(content: { AxisMarks(position: .top) }) - .chartYScale(domain: format == .celsius ? -20...55 : 0...125) + // .chartYScale(domain: format == .celsius ? -20...55 : 0...125) .chartForegroundStyleScale([ "Temperature": .clear ]) @@ -108,46 +84,19 @@ struct EnvironmentMetricsLog: View { } } else { ScrollView { - let columns = [ - GridItem(.flexible(minimum: 30, maximum: 50), spacing: 0.1), - GridItem(.flexible(minimum: 30, maximum: 60), spacing: 0.1), - GridItem(.flexible(minimum: 30, maximum: 60), spacing: 0.1), - GridItem(.flexible(minimum: 30, maximum: 70), spacing: 0.1), - GridItem(spacing: 0) - ] - LazyVGrid(columns: columns, alignment: .leading, spacing: 1, pinnedViews: [.sectionHeaders]) { - + LazyVGrid(columns: columnConfiguration.gridItems, alignment: .leading, spacing: 1, pinnedViews: [.sectionHeaders]) { GridRow { - Text("Temp") - .font(.caption) - .fontWeight(.bold) - Text("Hum") - .font(.caption) - .fontWeight(.bold) - Text("Bar") - .font(.caption) - .fontWeight(.bold) - Text("IAQ") - .font(.caption) - .fontWeight(.bold) - Text("timestamp") - .font(.caption) - .fontWeight(.bold) + ForEach(columnConfiguration.activeTableColumns) { col in + Text(col.abbreviatedColumnName) + .font(.caption) + .fontWeight(.bold) + } } ForEach(environmentMetrics, id: \.self) { em in - GridRow { - - Text(em.temperature.formattedTemperature()) - .font(.caption) - Text("\(String(format: "%.0f", em.relativeHumidity))%") - .font(.caption) - Text("\(String(format: "%.1f", em.barometricPressure))") - .font(.caption) - IndoorAirQuality(iaq: Int(em.iaq), displayMode: .dot) - .font(.caption) - Text(em.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized) - .font(.caption) + ForEach(columnConfiguration.activeTableColumns) { col in + col.tableBody(em) + } } } } @@ -157,7 +106,18 @@ struct EnvironmentMetricsLog: View { } } HStack { - + Button { + self.isEditingColumnConfiguration = true + } label: { + Label("Config", systemImage: "gearshape") + } .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) + .padding(.leading) + .sheet(isPresented: self.$isEditingColumnConfiguration) { + MetricsColumnDetail(metricsColumnConfiguration: self.columnConfiguration) + } Button(role: .destructive) { isPresentingClearLogConfirm = true } label: { diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentMetricsColumnDefaults.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentMetricsColumnDefaults.swift new file mode 100644 index 00000000..c3fc31b1 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentMetricsColumnDefaults.swift @@ -0,0 +1,207 @@ +// +// EnvironmentMetricsColumnDefaults.swift +// Meshtastic +// +// Created by Jake Bordens on 12/10/24. +// + +import Foundation +import SwiftUI +import Charts + +extension MetricsColumnConfiguration { + static var environmentDefaults: MetricsColumnConfiguration { MetricsColumnConfiguration(columns: [ + + // Temperature Series Configuration + MetricsColumnConfigurationEntry(attribute: "temperature", keyPath: \.temperature, + columnName: "Temperature", + abbreviatedColumnName: "Temp", + minWidth: 30, maxWidth: 50, + showInChart: true, + tableBody: { _, temp in + Text(temp.formattedTemperature()) + .font(.caption) + }, chartBody: { config, time, temperature in + AreaMark( + x: .value("Time", time), + y: .value(config.columnName, temperature.localeTemperature()), + series: .value("Metric", config.columnName), stacking: .unstacked + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [.blue, .yellow, .orange, .red, .red], + startPoint: .bottom, endPoint: .top + ) + .opacity(0.6) + ) + .alignsMarkStylesWithPlotArea() + .accessibilityHidden(true) + LineMark( + x: .value("Time", time), + y: .value(config.columnName, temperature.localeTemperature()), + series: .value("Metric", config.columnName) + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [.blue, .yellow, .orange, .red, .red], + startPoint: .bottom, endPoint: .top + ) + ) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + }), + + // Relative Humidity Series Configuration + MetricsColumnConfigurationEntry(attribute: "relativeHumidity", keyPath: \.relativeHumidity, + columnName: "Relative Humidity", + abbreviatedColumnName: "Hum", + minWidth: 30, maxWidth: 50, + tableBody: { _, humidity in + Text("\(String(format: "%.0f", humidity))%") + .font(.caption) + }, chartBody: { config, time, humidity in + LineMark( + x: .value("Time", time), + y: .value(config.columnName, humidity), + series: .value("Metric", config.columnName) + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [.gray, .blue], + startPoint: .bottom, endPoint: .top + ) + ) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + }), + + // Barometric Pressure Series Configuration + MetricsColumnConfigurationEntry(attribute: "barometricPressure", keyPath: \.barometricPressure, + columnName: "Barometric Pressure", + abbreviatedColumnName: "Bar", + minWidth: 30, maxWidth: 60, + tableBody: { _, pressure in + Text("\(String(format: "%.1f", pressure))") + .font(.caption) + }, chartBody: { config, time, pressure in + LineMark( + x: .value("Time", time), + y: .value(config.columnName, pressure), + series: .value("Metric", config.columnName) + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [.gray, .green], + startPoint: .bottom, endPoint: .top + ) + ) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + + }), + + // Indoor Air Quality Series Configuration + MetricsColumnConfigurationEntry(attribute: "iaq", keyPath: \.iaq, + columnName: "Indoor Air Quality", + abbreviatedColumnName: "IAQ", + minWidth: 30, maxWidth: 70, + tableBody: { _, iaq in + IndoorAirQuality(iaq: Int(iaq), displayMode: .dot) + .font(.caption) + }, chartBody: { config, time, iaq in + PointMark(x: .value("Time", time), + y: .value(config.columnName, 0.0)) + .symbol(Circle()) + .foregroundStyle(Iaq.getIaq(for: Int(iaq)).color) + }), + + // Wind Direction Series Configuration + MetricsColumnConfigurationEntry(attribute: "windDirection", keyPath: \.windDirection, + availability: .table, + columnName: "Wind Direction", + abbreviatedColumnName: "Dir", + minWidth: 30, maxWidth: 40, + tableBody: { _, wind in + Text(cardinalValue(from: Double(wind))) + .font(.caption) + }, chartBody: { _, _, _ in + + }), + + // Wind Speed Series Configuration + MetricsColumnConfigurationEntry(attribute: "windSpeed", keyPath: \.windSpeed, + availability: .table, + columnName: "Wind Speed", + abbreviatedColumnName: "Wind", + minWidth: 30, maxWidth: 40, + tableBody: { _, speed in + let windSpeed = Measurement(value: Double(speed), unit: UnitSpeed.kilometersPerHour) + Text(windSpeed.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0))))) + .font(.caption) + }, chartBody: { _, _, _ in + + }), + + // Combined Wind Speed and Direction Series Configuration -- For use in Chart only + MetricsColumnConfigurationEntry(attribute: "windSpeedAndDirection", keyPath: \.windSpeedAndDirection, + availability: .chart, + columnName: "Wind Speed/Direction", + abbreviatedColumnName: "Speed/Dir", + minWidth: 30, maxWidth: 40, + tableBody: { _, _ in + EmptyView() + }, chartBody: { config, time, wsad in + var wsad = (Float.random(in:0...25), Int32.random(in:0..<3)*90 ) + LineMark( + x: .value("Time", time), + y: .value(config.columnName, wsad.0), + series: .value("Metric", config.columnName) + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [Color(UIColor.yellow.darker()), .yellow], + startPoint: .bottom, endPoint: .top + ) + ) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + PointMark(x: .value("Time", time), + y: .value(config.columnName, wsad.0)) + .symbol { + Image(systemName: "location.north.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(Color.white, Color.yellow) + .rotationEffect(.degrees(Double(wsad.1))) + }.foregroundStyle(.yellow) + + }), + + + // Timestamp Series Configuration -- for use in table only + MetricsColumnConfigurationEntry(attribute: "time", keyPath: \.time, + availability: .table, + columnName: "Timestamp", + abbreviatedColumnName: "Time", + tableBody: { _, time in + let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current) + let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "") + Text(time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized) + .font(.caption) + }, chartBody: { _, _, _ in + + }) + ]) + } +} + + +extension TelemetryEntity { + var windSpeedAndDirection: (Float, Int32) { + return (self.windSpeed, self.windDirection) + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift new file mode 100644 index 00000000..2c3987a6 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift @@ -0,0 +1,54 @@ +// +// MetricsColumnDetail.swift +// Meshtastic +// +// Created by Jake Bordens on 12/10/24. +// + +import SwiftUI + +struct MetricsColumnDetail: View { + @ObservedObject var metricsColumnConfiguration: MetricsColumnConfiguration + @State private var currentDetent = PresentationDetent.medium + + var body: some View { + List { + Section("Chart") { + ForEach(metricsColumnConfiguration.columns.filter({$0.availability.contains(.chart)}), id:\.self) { column in + HStack { + Text(column.columnName) + Spacer() + if column.showInChart { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + }.contentShape(Rectangle()) // Ensures the entire row is tappable + .onTapGesture { + metricsColumnConfiguration.objectWillChange.send() + column.showInChart.toggle() + } + } + } + Section("Table") { + ForEach(metricsColumnConfiguration.columns.filter({$0.availability.contains(.table)}), id:\.self) { column in + HStack { + Text(column.columnName) + Spacer() + if column.showInTable { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + }.contentShape(Rectangle()) // Ensures the entire row is tappable + .onTapGesture { + metricsColumnConfiguration.objectWillChange.send() + column.showInTable.toggle() + } + } + } + } + .presentationDetents([.medium, .large], selection: $currentDetent) + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.visible) + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + } +} From a12d5584aabf0b0ae2851dccd7fd1ff6c5597a56 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Wed, 11 Dec 2024 20:44:44 -0500 Subject: [PATCH 04/14] Refinements to configurable metrics chart and table --- Meshtastic.xcodeproj/project.pbxproj | 42 ++-- .../MetricColumnConfigurationEntry.swift | 80 ------- .../MetricsColumnConfiguration.swift | 39 ---- .../MetricTableColumn.swift | 72 ++++++ .../MetricsChartSeries.swift | 58 +++++ .../MetricsColumnList.swift | 94 ++++++++ .../MetricsSeriesList.swift | 82 +++++++ .../Views/Nodes/EnvironmentMetricsLog.swift | 26 +-- .../EnviornmentDefaultSeries.swift | 171 +++++++++++++++ .../EnvironmentDefaultColumns.swift | 120 ++++++++++ .../EnvironmentMetricsColumnDefaults.swift | 207 ------------------ .../Metrics Columns/MetricsColumnDetail.swift | 33 +-- .../Views/Nodes/Helpers/NodeDetail.swift | 25 +++ 13 files changed, 678 insertions(+), 371 deletions(-) delete mode 100644 Meshtastic/Model/Metrics Columns/MetricColumnConfigurationEntry.swift delete mode 100644 Meshtastic/Model/Metrics Columns/MetricsColumnConfiguration.swift create mode 100644 Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift create mode 100644 Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift create mode 100644 Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift create mode 100644 Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift create mode 100644 Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift create mode 100644 Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift delete mode 100644 Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentMetricsColumnDefaults.swift diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 90bb4a30..8f80a7b3 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -7,10 +7,13 @@ objects = { /* Begin PBXBuildFile section */ - 231B3F212D087A4C0069A07D /* MetricColumnConfigurationEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F202D087A4C0069A07D /* MetricColumnConfigurationEntry.swift */; }; - 231B3F222D087A4C0069A07D /* MetricsColumnConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnConfiguration.swift */; }; - 231B3F252D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F242D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift */; }; + 231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */; }; + 231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */; }; + 231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */; }; 231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */; }; + 2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */; }; + 2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */; }; + 2373AE172D0A26620086C749 /* EnviornmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */; }; 251926852C3BA97800249DF5 /* FavoriteNodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */; }; 251926872C3BAE2200249DF5 /* NodeAlertsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */; }; 2519268A2C3BB1B200249DF5 /* ExchangePositionsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */; }; @@ -262,10 +265,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 231B3F1F2D087A4C0069A07D /* MetricsColumnConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnConfiguration.swift; sourceTree = ""; }; - 231B3F202D087A4C0069A07D /* MetricColumnConfigurationEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricColumnConfigurationEntry.swift; sourceTree = ""; }; - 231B3F242D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentMetricsColumnDefaults.swift; sourceTree = ""; }; + 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = ""; }; + 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = ""; }; + 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = ""; }; 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = ""; }; + 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = ""; }; + 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = ""; }; + 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnviornmentDefaultSeries.swift; sourceTree = ""; }; 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteNodeButton.swift; sourceTree = ""; }; 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeAlertsButton.swift; sourceTree = ""; }; 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangePositionsButton.swift; sourceTree = ""; }; @@ -560,19 +566,22 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 231B3F1E2D0879BC0069A07D /* Metrics Columns */ = { + 231B3F1E2D0879BC0069A07D /* Metrics Visualization */ = { isa = PBXGroup; children = ( - 231B3F1F2D087A4C0069A07D /* MetricsColumnConfiguration.swift */, - 231B3F202D087A4C0069A07D /* MetricColumnConfigurationEntry.swift */, + 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */, + 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */, + 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */, + 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */, ); - path = "Metrics Columns"; + path = "Metrics Visualization"; sourceTree = ""; }; 231B3F232D087C020069A07D /* Metrics Columns */ = { isa = PBXGroup; children = ( - 231B3F242D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift */, + 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */, + 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */, 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */, ); path = "Metrics Columns"; @@ -957,7 +966,7 @@ DDC2E18826CE24EE0042C5E4 /* Model */ = { isa = PBXGroup; children = ( - 231B3F1E2D0879BC0069A07D /* Metrics Columns */, + 231B3F1E2D0879BC0069A07D /* Metrics Visualization */, DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */, ); path = Model; @@ -1350,11 +1359,13 @@ DDB6ABD628AE742000384BA1 /* BluetoothConfig.swift in Sources */, 251926902C3CB44900249DF5 /* ClientHistoryButton.swift in Sources */, DDD5BB102C285FB3007E03CA /* AppLogFilter.swift in Sources */, + 2373AE172D0A26620086C749 /* EnviornmentDefaultSeries.swift in Sources */, DD4640202AFF10F4002A5ECB /* WaypointForm.swift in Sources */, DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */, DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */, DD15E4F32B8BA56E00654F61 /* PaxCounterConfig.swift in Sources */, DDDB445229F8ACF900EE2349 /* Date.swift in Sources */, + 2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */, DDC4D568275499A500A4208E /* Persistence.swift in Sources */, DDD6EEAF29BC024700383354 /* Firmware.swift in Sources */, DD77093B2AA1ABB8007A8BF0 /* BluetoothTips.swift in Sources */, @@ -1364,8 +1375,9 @@ DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */, DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */, DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */, - 231B3F252D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift in Sources */, + 231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */, 25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */, + 2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */, DD354FD92BD96A0B0061A25F /* IAQScale.swift in Sources */, DDDB445429F8AD1600EE2349 /* Data.swift in Sources */, DDDB26462AACC0B7003AFCB7 /* NodeInfoItem.swift in Sources */, @@ -1449,8 +1461,8 @@ DD3CC24C2C498D6C001BD3A2 /* BatteryCompact.swift in Sources */, BCB613812C67290800485544 /* SendWaypointIntent.swift in Sources */, DD1B8F402B35E2F10022AABC /* GPSStatus.swift in Sources */, - 231B3F212D087A4C0069A07D /* MetricColumnConfigurationEntry.swift in Sources */, - 231B3F222D087A4C0069A07D /* MetricsColumnConfiguration.swift in Sources */, + 231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */, + 231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */, DD8ED9C52898D51F00B3B0AB /* NetworkConfig.swift in Sources */, DDC3B274283F411B00AC321C /* LastHeardText.swift in Sources */, DDDE5A1029AFE69700490C6C /* MeshActivityAttributes.swift in Sources */, diff --git a/Meshtastic/Model/Metrics Columns/MetricColumnConfigurationEntry.swift b/Meshtastic/Model/Metrics Columns/MetricColumnConfigurationEntry.swift deleted file mode 100644 index 7ad10089..00000000 --- a/Meshtastic/Model/Metrics Columns/MetricColumnConfigurationEntry.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// SeriesConfigurationEntry.swift -// Meshtastic -// -// Created by Jake Bordens on 12/7/24. -// - -import SwiftUI -import Charts -import OSLog - -struct MetricVisualizationType: OptionSet { - let rawValue: Int - - static let chart = MetricVisualizationType(rawValue: 1 << 0) - static let table = MetricVisualizationType(rawValue: 1 << 1) - - static let all: MetricVisualizationType = [.chart, .table] -} -class MetricsColumnConfigurationEntry: ObservableObject { - let attribute: String // CoreData Attribute Name on TelemetryEntity - let availability: MetricVisualizationType // Determine where this attribute can appear - let columnName: String // Heading for wider tables - let abbreviatedColumnName: String // Heading for space-constrained tables - let minWidth: CGFloat? // Minimum grid width for this column - let maxWidth: CGFloat? // Maximum grid width for this column - let spacing: CGFloat // Recommended spacing, may be overridden - var showInTable: Bool // Should this column appear in the table - var showInChart: Bool // Should this column appear in the chart - let tableBodyClosure: (MetricsColumnConfigurationEntry, TelemetryEntity) -> AnyView // Closure to render the view - let chartBodyClosure: (MetricsColumnConfigurationEntry, TelemetryEntity) -> AnyChartContent // Closure to render the chart - - init(attribute: String, keyPath: KeyPath, - availability: MetricVisualizationType = .all, - columnName: String, abbreviatedColumnName: String, - minWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, spacing: CGFloat = 0.1, - showInTable: Bool = true, showInChart: Bool = false, - @ViewBuilder tableBody: @escaping (MetricsColumnConfigurationEntry, Value) -> TableContent, - @ChartContentBuilder chartBody: @escaping (MetricsColumnConfigurationEntry, Date, Value) -> ChartAxes) { - self.attribute = attribute - self.availability = availability - self.columnName = columnName - self.abbreviatedColumnName = abbreviatedColumnName - self.minWidth = minWidth - self.maxWidth = maxWidth - self.spacing = spacing - self.showInTable = showInTable - self.showInChart = showInChart - self.tableBodyClosure = { config, entity in AnyView(tableBody(config, entity[keyPath: keyPath])) } - self.chartBodyClosure = { config, entity in AnyChartContent(chartBody(config, entity.time!, entity[keyPath: keyPath])) } - } - - var gridItemSize: GridItem.Size { - if let minWidth, let maxWidth { - return .flexible(minimum: minWidth, maximum: maxWidth) - } - return .flexible() - } - - func tableBody(_ te: TelemetryEntity) -> AnyView { - return tableBodyClosure(self, te) - } - - func chartBody(_ te: TelemetryEntity) -> AnyChartContent { - return chartBodyClosure(self, te) - } - -} - -extension MetricsColumnConfigurationEntry: Identifiable, Hashable { - var id: String { self.attribute } - - static func == (lhs: MetricsColumnConfigurationEntry, rhs: MetricsColumnConfigurationEntry) -> Bool { - lhs.attribute == rhs.attribute - } - - func hash(into hasher: inout Hasher) { - hasher.combine(attribute) - } -} diff --git a/Meshtastic/Model/Metrics Columns/MetricsColumnConfiguration.swift b/Meshtastic/Model/Metrics Columns/MetricsColumnConfiguration.swift deleted file mode 100644 index c61116d9..00000000 --- a/Meshtastic/Model/Metrics Columns/MetricsColumnConfiguration.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// SeriesConfiguration.swift -// Meshtastic -// -// Created by Jake Bordens on 12/7/24. -// -import SwiftUI - -class MetricsColumnConfiguration: ObservableObject { - - @Published var columns: [MetricsColumnConfigurationEntry] - - init(columns: [MetricsColumnConfigurationEntry]) { - self.columns = columns - } - - var activeTableColumns: [MetricsColumnConfigurationEntry] { - return columns.filter { $0.showInTable && $0.availability.contains(.table)} - } - - var activeChartColumns: [MetricsColumnConfigurationEntry] { - return columns.filter { $0.showInChart } - } - - var gridItems: [GridItem] { - var returnValues: [GridItem] = [] - let columnsInChart = self.activeTableColumns - for i in 0.. AnyView? // Closure to render the view + + // Main initializer + init( + keyPath: KeyPath, + name: String, + abbreviatedName: String, + minWidth: CGFloat? = nil, + maxWidth: CGFloat? = nil, + spacing: CGFloat = 0.1, + visible: Bool = true, + @ViewBuilder tableBody: @escaping (MetricsTableColumn, Value) -> TableContent? + ) { + // This works because TelemetryEntity is an NSManagedObject and derrived from NSObject + self.attribute = NSExpression(forKeyPath: keyPath).keyPath + self.name = name + self.abbreviatedName = abbreviatedName + self.minWidth = minWidth + self.maxWidth = maxWidth + self.spacing = spacing + self.visible = visible + self.tableBodyClosure = { config, entity in + AnyView(tableBody(config, entity[keyPath: keyPath])) + } + } + + var gridItemSize: GridItem.Size { + if let minWidth, let maxWidth { + return .flexible(minimum: minWidth, maximum: maxWidth) + } + return .flexible() + } + + func body(_ te: TelemetryEntity) -> AnyView? { + return tableBodyClosure(self, te) + } +} + +extension MetricsTableColumn: Identifiable, Hashable { + var id: String { self.attribute } + + static func == (lhs: MetricsTableColumn, rhs: MetricsTableColumn) -> Bool { + lhs.attribute == rhs.attribute + } + + func hash(into hasher: inout Hasher) { + hasher.combine(attribute) + } +} diff --git a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift new file mode 100644 index 00000000..d052cb74 --- /dev/null +++ b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift @@ -0,0 +1,58 @@ +// +// MetricsChartSeries.swift +// Meshtastic +// +// Created by Jake Bordens on 12/11/24. +// + +import Charts +import Foundation +import SwiftUI + +// MetricsChartSeries stores metadata about an attribute in TelemetryEntity. +// Given a keypath, this class holds information about how to render the attrbute in a +// the chart. MetricsChartSeries objects are collected in a MetricsSeriesList +class MetricsChartSeries: ObservableObject { + + let attribute: String // CoreData Attribute Name on TelemetryEntity + let name: String // Heading for wider tables + let abbreviatedName: String // Heading for space-constrained tables + var visible: Bool // Should this column appear in the table + let chartBodyClosure: + (MetricsChartSeries, TelemetryEntity) -> AnyChartContent? // Closure to render the chart + + // Main initializer + init( + keyPath: KeyPath, + name: String, + abbreviatedName: String, + visible: Bool = true, + @ChartContentBuilder chartBody: @escaping (MetricsChartSeries, Date, Value) -> ChartBody? + ) { + // This works because TelemetryEntity is an NSManagedObject and derrived from NSObject + self.attribute = NSExpression(forKeyPath: keyPath).keyPath + self.name = name + self.abbreviatedName = abbreviatedName + self.visible = visible + self.chartBodyClosure = { series, entity in + AnyChartContent( + chartBody(series, entity.time!, entity[keyPath: keyPath])) + } + } + + func body(_ te: TelemetryEntity) -> AnyChartContent? { + return chartBodyClosure(self, te) + } +} + +extension MetricsChartSeries: Identifiable, Hashable { + var id: String { self.attribute } + + static func == (lhs: MetricsChartSeries, rhs: MetricsChartSeries) -> Bool { + lhs.attribute == rhs.attribute + } + + func hash(into hasher: inout Hasher) { + hasher.combine(attribute) + } +} diff --git a/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift b/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift new file mode 100644 index 00000000..9068b0a8 --- /dev/null +++ b/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift @@ -0,0 +1,94 @@ +// +// SeriesConfiguration.swift +// Meshtastic +// +// Created by Jake Bordens on 12/7/24. +// +import SwiftUI + +class MetricsColumnList: ObservableObject, RandomAccessCollection, RangeReplaceableCollection { + + @Published var columns: [MetricsTableColumn] + + init(columns: [MetricsTableColumn]) { + self.columns = columns + } + + var visible: [MetricsTableColumn] { + return columns.filter { $0.visible } + } + + func toggleVisibity(for column: MetricsTableColumn) { + if columns.contains(column) { + self.objectWillChange.send() + column.visible.toggle() + } + } + + var gridItems: [GridItem] { + var returnValues: [GridItem] = [] + let columnsInChart = self.visible + for i in 0.. + + required init() { columns = [] } + required init(_ columns: S) where S.Element == Element { + self.columns = Array(columns) + } + + var startIndex: Int { columns.startIndex } + var endIndex: Int { columns.endIndex } + + subscript(position: Int) -> Element { + get { columns[position] } + set { + objectWillChange.send() + columns[position] = newValue + } + } + subscript(bounds: Range) -> ArraySlice { columns[bounds] } + func index(after i: Int) -> Int { columns.index(after: i) } + + func replaceSubrange(_ subrange: Range, with newElements: C) where C.Element == Element { + objectWillChange.send() + columns.replaceSubrange(subrange, with: newElements) + } + + func append(_ newElement: Element) { + columns.append(newElement) + objectWillChange.send() + } + + func remove(at index: Int) -> Element { + objectWillChange.send() + let removedElement = columns.remove(at: index) + return removedElement + } + + func removeAll() { + objectWillChange.send() + columns.removeAll() + } + + func insert(_ newElement: Element, at index: Int) { + objectWillChange.send() + columns.insert(newElement, at: index) + } +} diff --git a/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift b/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift new file mode 100644 index 00000000..53224d88 --- /dev/null +++ b/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift @@ -0,0 +1,82 @@ +// +// MetricsChartSeriesList.swift +// Meshtastic +// +// Created by Jake Bordens on 12/11/24. +// + +import Foundation +import SwiftUI +class MetricsSeriesList: ObservableObject, RandomAccessCollection, RangeReplaceableCollection { + + @Published var series: [MetricsChartSeries] + + var visible: [MetricsChartSeries] { + return series.filter { $0.visible } + } + + func toggleVisibity(for aSeries: MetricsChartSeries) { + if series.contains(aSeries) { + self.objectWillChange.send() + aSeries.visible.toggle() + } + } + + var foregroundStyles: Dictionary { + var dict = Dictionary() + for aSeries in series { + dict[aSeries.name] = .clear + } + return dict + } + + // Collection conformance + typealias Index = Int + typealias Element = MetricsChartSeries + typealias SubSequence = ArraySlice + + required init() { series = [] } + required init(_ series: S) where S.Element == Element { + self.series = Array(series) + } + + var startIndex: Int { series.startIndex } + var endIndex: Int { series.endIndex } + + subscript(position: Int) -> Element { + get { series[position] } + set { + objectWillChange.send() + series[position] = newValue + } + } + subscript(bounds: Range) -> ArraySlice { series[bounds] } + func index(after i: Int) -> Int { series.index(after: i) } + + func replaceSubrange(_ subrange: Range, with newElements: C) where C.Element == Element { + objectWillChange.send() + series.replaceSubrange(subrange, with: newElements) + } + + func append(_ newElement: Element) { + series.append(newElement) + objectWillChange.send() + } + + func remove(at index: Int) -> Element { + objectWillChange.send() + let removedElement = series.remove(at: index) + return removedElement + } + + func removeAll() { + objectWillChange.send() + series.removeAll() + } + + func insert(_ newElement: Element, at index: Int) { + objectWillChange.send() + series.insert(newElement, at: index) + } + +} diff --git a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift index ebd5819d..8bda22b1 100644 --- a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift +++ b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift @@ -17,7 +17,9 @@ struct EnvironmentMetricsLog: View { @State var exportString = "" @ObservedObject var node: NodeInfoEntity - @StateObject var columnConfiguration = MetricsColumnConfiguration.environmentDefaults + @StateObject var columnList = MetricsColumnList.environmentDefaultColumns + @StateObject var seriesList = MetricsSeriesList.environmentDefaultChartSeries + @State var isEditingColumnConfiguration = false var body: some View { @@ -28,24 +30,18 @@ struct EnvironmentMetricsLog: View { let chartData = environmentMetrics .filter { $0.time != nil && $0.time! >= oneWeekAgo! } .sorted { $0.time! < $1.time! } - let locale = NSLocale.current as NSLocale - let localeUnit = locale.object(forKey: NSLocale.Key(rawValue: "kCFLocaleTemperatureUnitKey")) - let format: UnitTemperature = localeUnit as? String ?? "Celsius" == "Fahrenheit" ? .fahrenheit : .celsius VStack { if chartData.count > 0 { GroupBox(label: Label("\(environmentMetrics.count) Readings Total", systemImage: "chart.xyaxis.line")) { - Chart(columnConfiguration.activeChartColumns, id: \.columnName) { series in + Chart(seriesList.visible) { series in ForEach(chartData, id: \.time) { dataPoint in - series.chartBody(dataPoint) + series.body(dataPoint) } } .chartXAxis(content: { AxisMarks(position: .top) }) // .chartYScale(domain: format == .celsius ? -20...55 : 0...125) - .chartForegroundStyleScale([ - "Temperature": .clear - ]) .chartLegend(position: .automatic, alignment: .bottom) } } @@ -84,18 +80,18 @@ struct EnvironmentMetricsLog: View { } } else { ScrollView { - LazyVGrid(columns: columnConfiguration.gridItems, alignment: .leading, spacing: 1, pinnedViews: [.sectionHeaders]) { + LazyVGrid(columns: columnList.gridItems, alignment: .leading, spacing: 1, pinnedViews: [.sectionHeaders]) { GridRow { - ForEach(columnConfiguration.activeTableColumns) { col in - Text(col.abbreviatedColumnName) + ForEach(columnList.visible) { col in + Text(col.abbreviatedName) .font(.caption) .fontWeight(.bold) } } ForEach(environmentMetrics, id: \.self) { em in GridRow { - ForEach(columnConfiguration.activeTableColumns) { col in - col.tableBody(em) + ForEach(columnList.visible) { col in + col.body(em) } } } @@ -116,7 +112,7 @@ struct EnvironmentMetricsLog: View { .padding(.bottom) .padding(.leading) .sheet(isPresented: self.$isEditingColumnConfiguration) { - MetricsColumnDetail(metricsColumnConfiguration: self.columnConfiguration) + MetricsColumnDetail(columnList: columnList, seriesList: seriesList) } Button(role: .destructive) { isPresentingClearLogConfirm = true diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift new file mode 100644 index 00000000..0b6bdf9c --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift @@ -0,0 +1,171 @@ +// +// EnvironmentDefaultSeries.swift +// Meshtastic +// +// Created by Jake Bordens on 12/11/24. +// + +import Charts +import Foundation +import SwiftUI + +// This is the default configuration used by the EnvironmentMetricsLog view for the chart +extension MetricsSeriesList { + static var environmentDefaultChartSeries: MetricsSeriesList { + MetricsSeriesList([ + // Temperature Series Configuration + MetricsChartSeries( + keyPath: \.temperature, + name: "Temperature", + abbreviatedName: "Temp", + chartBody: { series, time, temperature in + AreaMark( + x: .value("Time", time), + y: .value( + series.name, temperature.localeTemperature()), + series: .value("Metric", series.name), + stacking: .unstacked + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [.blue, .yellow, .orange, .red, .red], + startPoint: .bottom, endPoint: .top + ) + .opacity(0.6) + ) + .alignsMarkStylesWithPlotArea() + .accessibilityHidden(true) + LineMark( + x: .value("Time", time), + y: .value( + series.name, temperature.localeTemperature()), + series: .value("Metric", series.name) + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [.blue, .yellow, .orange, .red, .red], + startPoint: .bottom, endPoint: .top + ) + ) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + }), + + // Relative Humidity Series Configuration + MetricsChartSeries( + keyPath: \.relativeHumidity, + name: "Relative Humidity", + abbreviatedName: "Hum", + chartBody: { series, time, humidity in + LineMark( + x: .value("Time", time), + y: .value(series.name, humidity), + series: .value("Metric", series.name) + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [.gray, .blue], + startPoint: .bottom, endPoint: .top + ) + ) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + }), + + // Barometric Pressure Series Configuration + MetricsChartSeries( + keyPath: \.barometricPressure, + name: "Barometric Pressure", + abbreviatedName: "Bar", + visible: false, + chartBody: { series, time, pressure in + LineMark( + x: .value("Time", time), + y: .value(series.name, pressure), + series: .value("Metric", series.name) + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [.gray, .green], + startPoint: .bottom, endPoint: .top + ) + ) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + + }), + + // Indoor Air Quality Series Configuration + MetricsChartSeries( + keyPath: \.iaq, + name: "Indoor Air Quality", + abbreviatedName: "IAQ", + visible: false, + chartBody: { series, time, iaq in + let iaqEnum = Iaq.getIaq(for: Int(iaq)) + PointMark( + x: .value("Time", time), + y: .value(series.name, Float(iaq)) + ) + .symbol(Circle()) + .foregroundStyle(iaqEnum.color) + }), + + // Combined Wind Speed and Direction Series Configuration -- For use in Chart only + MetricsChartSeries( + keyPath: \.windSpeedAndDirection, + name: "Wind Speed/Direction", + abbreviatedName: "Speed/Dir", + visible: false, + chartBody: { series, time, wsad in + // debug data: var wsad = WindSpeedAndDirection(windSpeed:Float.random(in:0...25), windDirection: Int32.random(in:0..<3)*90 ) + LineMark( + x: .value("Time", time), + y: .value(series.name, wsad.windSpeed), + series: .value("Metric", series.name) + ) + .interpolationMethod(.cardinal) + .foregroundStyle( + .linearGradient( + colors: [Color(UIColor.yellow.darker()), .yellow], + startPoint: .bottom, endPoint: .top + ) + ) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + PointMark( + x: .value("Time", time), + y: .value(series.name, wsad.windSpeed) + ) + .symbol { + Image(systemName: "location.north.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(Color.white, Color.yellow) + .rotationEffect( + .degrees(Double(wsad.windDirection))) + }.foregroundStyle(.yellow) + }) + ]) + } +} + +// Extension to combine windspeed and direction into one attribute for rendering +// for rendering on the chart. +@objc class WindSpeedAndDirection: NSObject { + let windSpeed: Float + let windDirection: Int32 + init(windSpeed: Float, windDirection: Int32) { + self.windSpeed = windSpeed + self.windDirection = windDirection + } +} +@objc extension TelemetryEntity { + var windSpeedAndDirection: WindSpeedAndDirection { + return WindSpeedAndDirection( + windSpeed: self.windSpeed, windDirection: self.windDirection) + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift new file mode 100644 index 00000000..0821330f --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift @@ -0,0 +1,120 @@ +// +// EnvironmentDefaultColumns.swift +// Meshtastic +// +// Created by Jake Bordens on 12/10/24. +// + +import Charts +import Foundation +import SwiftUI + +// This is the default configuration used by the EnvironmentMetricsLog view for the table +extension MetricsColumnList { + static var environmentDefaultColumns: MetricsColumnList { + MetricsColumnList(columns: [ + // Temperature Series Configuration + MetricsTableColumn( + keyPath: \.temperature, + name: "Temperature", + abbreviatedName: "Temp", + minWidth: 25, maxWidth: 40, + tableBody: { _, temp in + Text(temp.formattedTemperature()) + .font(.caption) + }), + + // Relative Humidity Series Configuration + MetricsTableColumn( + keyPath: \.relativeHumidity, + name: "Relative Humidity", + abbreviatedName: "Hum", + minWidth: 25, maxWidth: 40, + tableBody: { _, humidity in + Text("\(String(format: "%.0f", humidity))%") + .font(.caption) + }), + + // Barometric Pressure Series Configuration + MetricsTableColumn( + keyPath: \.barometricPressure, + name: "Barometric Pressure", + abbreviatedName: "Bar", + minWidth: 30, maxWidth: 50, + tableBody: { _, pressure in + Text("\(String(format: "%.1f", pressure))") + .font(.caption) + }), + + // Indoor Air Quality Series Configuration + MetricsTableColumn( + keyPath: \.iaq, + name: "Indoor Air Quality", + abbreviatedName: "IAQ", + minWidth: 25, maxWidth: 50, + tableBody: { _, iaq in + IndoorAirQuality(iaq: Int(iaq), displayMode: .dot) + .font(.caption) + }), + + // Wind Direction Series Configuration + MetricsTableColumn( + keyPath: \.windDirection, + name: "Wind Direction", + abbreviatedName: "Dir", + minWidth: 30, maxWidth: 40, + visible: false, + tableBody: { _, wind in + HStack(spacing: 1.0) { + // debug data: let wind = Double.random(in: 0..<360.0) + let wind = Double(wind) + Image(systemName: "location.north") + .imageScale(.small) + .rotationEffect(.degrees(wind)) + Text(abbreviatedCardinalValue(from: wind)) + .font(.caption) + } + }), + + // Wind Speed Series Configuration + MetricsTableColumn( + keyPath: \.windSpeed, + name: "Wind Speed", + abbreviatedName: "Wind", + minWidth: 30, maxWidth: 40, + visible: false, + tableBody: { _, speed in + let windSpeed = Measurement( + value: Double(speed), unit: UnitSpeed.kilometersPerHour) + Text( + windSpeed.formatted( + .measurement( + width: .abbreviated, + numberFormatStyle: .number.precision( + .fractionLength(0)))) + ) + .font(.caption) + }), + + // Timestamp Series Configuration -- for use in table only + MetricsTableColumn( + keyPath: \.time, + name: "Timestamp", + abbreviatedName: "Time", + minWidth: 140.0, maxWidth: 2000.0, + tableBody: { _, time in + let localeDateFormat = DateFormatter.dateFormat( + fromTemplate: "yyMMddjmma", options: 0, + locale: Locale.current) + let dateFormatString = + (localeDateFormat ?? "MM/dd/YY j:mma") + .replacingOccurrences(of: ",", with: "") + Text( + time?.formattedDate(format: dateFormatString) + ?? "unknown.age".localized + ) + .font(.caption) + }) + ]) + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentMetricsColumnDefaults.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentMetricsColumnDefaults.swift deleted file mode 100644 index c3fc31b1..00000000 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentMetricsColumnDefaults.swift +++ /dev/null @@ -1,207 +0,0 @@ -// -// EnvironmentMetricsColumnDefaults.swift -// Meshtastic -// -// Created by Jake Bordens on 12/10/24. -// - -import Foundation -import SwiftUI -import Charts - -extension MetricsColumnConfiguration { - static var environmentDefaults: MetricsColumnConfiguration { MetricsColumnConfiguration(columns: [ - - // Temperature Series Configuration - MetricsColumnConfigurationEntry(attribute: "temperature", keyPath: \.temperature, - columnName: "Temperature", - abbreviatedColumnName: "Temp", - minWidth: 30, maxWidth: 50, - showInChart: true, - tableBody: { _, temp in - Text(temp.formattedTemperature()) - .font(.caption) - }, chartBody: { config, time, temperature in - AreaMark( - x: .value("Time", time), - y: .value(config.columnName, temperature.localeTemperature()), - series: .value("Metric", config.columnName), stacking: .unstacked - ) - .interpolationMethod(.cardinal) - .foregroundStyle( - .linearGradient( - colors: [.blue, .yellow, .orange, .red, .red], - startPoint: .bottom, endPoint: .top - ) - .opacity(0.6) - ) - .alignsMarkStylesWithPlotArea() - .accessibilityHidden(true) - LineMark( - x: .value("Time", time), - y: .value(config.columnName, temperature.localeTemperature()), - series: .value("Metric", config.columnName) - ) - .interpolationMethod(.cardinal) - .foregroundStyle( - .linearGradient( - colors: [.blue, .yellow, .orange, .red, .red], - startPoint: .bottom, endPoint: .top - ) - ) - .lineStyle(StrokeStyle(lineWidth: 4)) - .alignsMarkStylesWithPlotArea() - }), - - // Relative Humidity Series Configuration - MetricsColumnConfigurationEntry(attribute: "relativeHumidity", keyPath: \.relativeHumidity, - columnName: "Relative Humidity", - abbreviatedColumnName: "Hum", - minWidth: 30, maxWidth: 50, - tableBody: { _, humidity in - Text("\(String(format: "%.0f", humidity))%") - .font(.caption) - }, chartBody: { config, time, humidity in - LineMark( - x: .value("Time", time), - y: .value(config.columnName, humidity), - series: .value("Metric", config.columnName) - ) - .interpolationMethod(.cardinal) - .foregroundStyle( - .linearGradient( - colors: [.gray, .blue], - startPoint: .bottom, endPoint: .top - ) - ) - .lineStyle(StrokeStyle(lineWidth: 4)) - .alignsMarkStylesWithPlotArea() - }), - - // Barometric Pressure Series Configuration - MetricsColumnConfigurationEntry(attribute: "barometricPressure", keyPath: \.barometricPressure, - columnName: "Barometric Pressure", - abbreviatedColumnName: "Bar", - minWidth: 30, maxWidth: 60, - tableBody: { _, pressure in - Text("\(String(format: "%.1f", pressure))") - .font(.caption) - }, chartBody: { config, time, pressure in - LineMark( - x: .value("Time", time), - y: .value(config.columnName, pressure), - series: .value("Metric", config.columnName) - ) - .interpolationMethod(.cardinal) - .foregroundStyle( - .linearGradient( - colors: [.gray, .green], - startPoint: .bottom, endPoint: .top - ) - ) - .lineStyle(StrokeStyle(lineWidth: 4)) - .alignsMarkStylesWithPlotArea() - - }), - - // Indoor Air Quality Series Configuration - MetricsColumnConfigurationEntry(attribute: "iaq", keyPath: \.iaq, - columnName: "Indoor Air Quality", - abbreviatedColumnName: "IAQ", - minWidth: 30, maxWidth: 70, - tableBody: { _, iaq in - IndoorAirQuality(iaq: Int(iaq), displayMode: .dot) - .font(.caption) - }, chartBody: { config, time, iaq in - PointMark(x: .value("Time", time), - y: .value(config.columnName, 0.0)) - .symbol(Circle()) - .foregroundStyle(Iaq.getIaq(for: Int(iaq)).color) - }), - - // Wind Direction Series Configuration - MetricsColumnConfigurationEntry(attribute: "windDirection", keyPath: \.windDirection, - availability: .table, - columnName: "Wind Direction", - abbreviatedColumnName: "Dir", - minWidth: 30, maxWidth: 40, - tableBody: { _, wind in - Text(cardinalValue(from: Double(wind))) - .font(.caption) - }, chartBody: { _, _, _ in - - }), - - // Wind Speed Series Configuration - MetricsColumnConfigurationEntry(attribute: "windSpeed", keyPath: \.windSpeed, - availability: .table, - columnName: "Wind Speed", - abbreviatedColumnName: "Wind", - minWidth: 30, maxWidth: 40, - tableBody: { _, speed in - let windSpeed = Measurement(value: Double(speed), unit: UnitSpeed.kilometersPerHour) - Text(windSpeed.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0))))) - .font(.caption) - }, chartBody: { _, _, _ in - - }), - - // Combined Wind Speed and Direction Series Configuration -- For use in Chart only - MetricsColumnConfigurationEntry(attribute: "windSpeedAndDirection", keyPath: \.windSpeedAndDirection, - availability: .chart, - columnName: "Wind Speed/Direction", - abbreviatedColumnName: "Speed/Dir", - minWidth: 30, maxWidth: 40, - tableBody: { _, _ in - EmptyView() - }, chartBody: { config, time, wsad in - var wsad = (Float.random(in:0...25), Int32.random(in:0..<3)*90 ) - LineMark( - x: .value("Time", time), - y: .value(config.columnName, wsad.0), - series: .value("Metric", config.columnName) - ) - .interpolationMethod(.cardinal) - .foregroundStyle( - .linearGradient( - colors: [Color(UIColor.yellow.darker()), .yellow], - startPoint: .bottom, endPoint: .top - ) - ) - .lineStyle(StrokeStyle(lineWidth: 4)) - .alignsMarkStylesWithPlotArea() - PointMark(x: .value("Time", time), - y: .value(config.columnName, wsad.0)) - .symbol { - Image(systemName: "location.north.circle.fill") - .symbolRenderingMode(.palette) - .foregroundStyle(Color.white, Color.yellow) - .rotationEffect(.degrees(Double(wsad.1))) - }.foregroundStyle(.yellow) - - }), - - - // Timestamp Series Configuration -- for use in table only - MetricsColumnConfigurationEntry(attribute: "time", keyPath: \.time, - availability: .table, - columnName: "Timestamp", - abbreviatedColumnName: "Time", - tableBody: { _, time in - let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current) - let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "") - Text(time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized) - .font(.caption) - }, chartBody: { _, _, _ in - - }) - ]) - } -} - - -extension TelemetryEntity { - var windSpeedAndDirection: (Float, Int32) { - return (self.windSpeed, self.windDirection) - } -} diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift index 2c3987a6..6f58a077 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift @@ -8,41 +8,43 @@ import SwiftUI struct MetricsColumnDetail: View { - @ObservedObject var metricsColumnConfiguration: MetricsColumnConfiguration + @ObservedObject var columnList: MetricsColumnList + @ObservedObject var seriesList: MetricsSeriesList + @State private var currentDetent = PresentationDetent.medium var body: some View { List { Section("Chart") { - ForEach(metricsColumnConfiguration.columns.filter({$0.availability.contains(.chart)}), id:\.self) { column in + ForEach(seriesList) { series in HStack { - Text(column.columnName) + Text(series.name) Spacer() - if column.showInChart { + if series.visible { Image(systemName: "checkmark") .foregroundColor(.blue) } - }.contentShape(Rectangle()) // Ensures the entire row is tappable + }.contentShape(Rectangle()) // Ensures the entire row is tappable .onTapGesture { - metricsColumnConfiguration.objectWillChange.send() - column.showInChart.toggle() - } + seriesList.objectWillChange.send() + series.visible.toggle() + } } } Section("Table") { - ForEach(metricsColumnConfiguration.columns.filter({$0.availability.contains(.table)}), id:\.self) { column in + ForEach(columnList.columns) { column in HStack { - Text(column.columnName) + Text(column.name) Spacer() - if column.showInTable { + if column.visible { Image(systemName: "checkmark") .foregroundColor(.blue) } - }.contentShape(Rectangle()) // Ensures the entire row is tappable + }.contentShape(Rectangle()) // Ensures the entire row is tappable .onTapGesture { - metricsColumnConfiguration.objectWillChange.send() - column.showInTable.toggle() - } + columnList.objectWillChange.send() + column.visible.toggle() + } } } } @@ -50,5 +52,6 @@ struct MetricsColumnDetail: View { .presentationContentInteraction(.scrolls) .presentationDragIndicator(.visible) .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + .interactiveDismissDisabled(false) } } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index a9f7bcfe..d37638a2 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -434,3 +434,28 @@ func cardinalValue(from heading: Double) -> String { return "" } } + +func abbreviatedCardinalValue(from heading: Double) -> String { + switch heading { + case 0 ..< 22.5: + return "N" + case 22.5 ..< 67.5: + return "NE" + case 67.5 ..< 112.5: + return "E" + case 112.5 ..< 157.5: + return "E" + case 157.5 ..< 202.5: + return "S" + case 202.5 ..< 247.5: + return "SW" + case 247.5 ..< 292.5: + return "W" + case 292.5 ..< 337.5: + return "NW" + case 337.5 ... 360.0: + return "N" + default: + return "" + } +} From 76dfc9647e685e6b1e90643ad9c0b2245936dc44 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Fri, 13 Dec 2024 08:01:09 -0500 Subject: [PATCH 05/14] Additional refinements to configurable columns and charts --- Localizable.xcstrings | 3 + .../MetricTableColumn.swift | 28 ++- .../MetricsChartSeries.swift | 91 ++++++++-- .../MetricsColumnList.swift | 18 +- .../MetricsSeriesList.swift | 61 +++++-- .../Views/Nodes/EnvironmentMetricsLog.swift | 17 +- .../EnviornmentDefaultSeries.swift | 159 +++++++++++------- .../Metrics Columns/MetricsColumnDetail.swift | 8 +- 8 files changed, 276 insertions(+), 109 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 43ac3828..e8957a07 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -20613,6 +20613,9 @@ } } } + }, + "Series" : { + }, "Server" : { "localizations" : { diff --git a/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift b/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift index fc364cec..188e4eba 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift @@ -13,15 +13,27 @@ import SwiftUI // Given a keypath, this class holds information about how to render the attrbute in // the table. MetricsTableColumn objects are collected in a MetricsColumnList class MetricsTableColumn: ObservableObject { + // CoreData Attribute Name on TelemetryEntity + let attribute: String - let attribute: String // CoreData Attribute Name on TelemetryEntity - let name: String // Heading for wider tables - let abbreviatedName: String // Heading for space-constrained tables - let minWidth: CGFloat? // Minimum grid width for this column - let maxWidth: CGFloat? // Maximum grid width for this column - let spacing: CGFloat // Recommended spacing, may be overridden - var visible: Bool // Should this column appear in the table - let tableBodyClosure: (MetricsTableColumn, TelemetryEntity) -> AnyView? // Closure to render the view + // Heading for wider tables + let name: String + + // Heading for space-constrained tables + let abbreviatedName: String + + // Minimum/maximum grid width for this column + let minWidth: CGFloat? + let maxWidth: CGFloat? + + // Recommended spacing, may be overridden + let spacing: CGFloat + // Should this column appear in the table + + var visible: Bool + + // Closure to render the table cell + let tableBodyClosure: (MetricsTableColumn, TelemetryEntity) -> AnyView? // Main initializer init( diff --git a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift index d052cb74..0766ae72 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift @@ -13,35 +13,81 @@ import SwiftUI // Given a keypath, this class holds information about how to render the attrbute in a // the chart. MetricsChartSeries objects are collected in a MetricsSeriesList class MetricsChartSeries: ObservableObject { - - let attribute: String // CoreData Attribute Name on TelemetryEntity - let name: String // Heading for wider tables - let abbreviatedName: String // Heading for space-constrained tables - var visible: Bool // Should this column appear in the table + + // CoreData Attribute Name on TelemetryEntity + let attribute: String + + // Heading for areas that have the room + let name: String + + // Heading for space-constrained areas + let abbreviatedName: String + + // Should this column appear in the chart + var visible: Bool + + // A closure that will provide the foreground style given the data set and overall chart range + let foregroundStyle: (ClosedRange?) -> AnyShapeStyle? + + // A closure that will provide the Chart Content for this series let chartBodyClosure: - (MetricsChartSeries, TelemetryEntity) -> AnyChartContent? // Closure to render the chart + (MetricsChartSeries, ClosedRange?, TelemetryEntity) -> AnyChartContent? // Closure to render the chart + + // A closure that will privide the value of a TelemetryEntity for this series + // Possibly converted to the proper units + let valueClosure: (TelemetryEntity) -> Float? // Main initializer - init( + init( keyPath: KeyPath, name: String, abbreviatedName: String, + conversion: ((Value) -> Value)? = nil, visible: Bool = true, - @ChartContentBuilder chartBody: @escaping (MetricsChartSeries, Date, Value) -> ChartBody? - ) { + foregroundStyle: @escaping ((ClosedRange?) -> ForegroundStyle?) = { _ in nil }, + @ChartContentBuilder chartBody: @escaping (MetricsChartSeries, ClosedRange?, Date, Value) -> ChartBody? + ) where Value: Plottable & Comparable { + // This works because TelemetryEntity is an NSManagedObject and derrived from NSObject self.attribute = NSExpression(forKeyPath: keyPath).keyPath self.name = name self.abbreviatedName = abbreviatedName self.visible = visible - self.chartBodyClosure = { series, entity in + + // By saving these closures, MetricsChartSeries can be type agnostic + // This is a less elegant form of type erasure, but doesn't require a new Any-type + self.foregroundStyle = { range in foregroundStyle(range).map({ AnyShapeStyle($0) }) } + self.chartBodyClosure = { series, range, entity in AnyChartContent( - chartBody(series, entity.time!, entity[keyPath: keyPath])) + chartBody(series, range, entity.time!, entity[keyPath: keyPath])) + } + self.valueClosure = { te in + if let conversion { + return conversion(te[keyPath: keyPath]).floatValue + } + return te[keyPath: keyPath].floatValue } } - func body(_ te: TelemetryEntity) -> AnyChartContent? { - return chartBodyClosure(self, te) +// // Return the maximum value for this series attribute given the data +// func max(forData: [TelemetryEntity]) -> Float? { +// return forData.compactMap { self.valueClosure($0) }.max() +// } +// +// // Return the minimum value for this series attribute given the data +// func min(forData: [TelemetryEntity]) -> Float? { +// return forData.compactMap { self.valueClosure($0) }.min() +// } +// + // Return the value for this series attribute given a full row of telemetry data + func valueFor(_ te: TelemetryEntity) -> Float? { + return self.valueClosure(te)?.floatValue + } + + // Return the chart content for this series given a full row of telemetry data + func body(_ te: TelemetryEntity, inChartRange chartRange: ClosedRange? = nil) -> AnyChartContent? where T: BinaryFloatingPoint { + let range = chartRange.map { Float($0.lowerBound)...Float($0.upperBound) } + return chartBodyClosure(self, range, te) } } @@ -56,3 +102,22 @@ extension MetricsChartSeries: Identifiable, Hashable { hasher.combine(attribute) } } + +extension Plottable { + var floatValue: Float? { + if let integerValue = self.primitivePlottable as? any BinaryInteger { + return Float(integerValue) + } else if let floatingPointValue = self.primitivePlottable as? any BinaryFloatingPoint { + return Float(floatingPointValue) + } + return nil + } + var doubleValue: Double? { + if let integerValue = self.primitivePlottable as? any BinaryInteger { + return Double(integerValue) + } else if let floatingPointValue = self.primitivePlottable as? any BinaryFloatingPoint { + return Double(floatingPointValue) + } + return nil + } +} diff --git a/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift b/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift index 9068b0a8..cd843d6d 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift @@ -17,7 +17,7 @@ class MetricsColumnList: ObservableObject, RandomAccessCollection, RangeReplacea var visible: [MetricsTableColumn] { return columns.filter { $0.visible } } - + func toggleVisibity(for column: MetricsTableColumn) { if columns.contains(column) { self.objectWillChange.send() @@ -47,15 +47,15 @@ class MetricsColumnList: ObservableObject, RandomAccessCollection, RangeReplacea typealias Index = Int typealias Element = MetricsTableColumn typealias SubSequence = ArraySlice - + required init() { columns = [] } required init(_ columns: S) where S.Element == Element { self.columns = Array(columns) } - + var startIndex: Int { columns.startIndex } var endIndex: Int { columns.endIndex } - + subscript(position: Int) -> Element { get { columns[position] } set { @@ -65,28 +65,28 @@ class MetricsColumnList: ObservableObject, RandomAccessCollection, RangeReplacea } subscript(bounds: Range) -> ArraySlice { columns[bounds] } func index(after i: Int) -> Int { columns.index(after: i) } - + func replaceSubrange(_ subrange: Range, with newElements: C) where C.Element == Element { objectWillChange.send() columns.replaceSubrange(subrange, with: newElements) } - + func append(_ newElement: Element) { columns.append(newElement) objectWillChange.send() } - + func remove(at index: Int) -> Element { objectWillChange.send() let removedElement = columns.remove(at: index) return removedElement } - + func removeAll() { objectWillChange.send() columns.removeAll() } - + func insert(_ newElement: Element, at index: Int) { objectWillChange.send() columns.insert(newElement, at: index) diff --git a/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift b/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift index 53224d88..049d1fb4 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift @@ -8,7 +8,7 @@ import Foundation import SwiftUI class MetricsSeriesList: ObservableObject, RandomAccessCollection, RangeReplaceableCollection { - + @Published var series: [MetricsChartSeries] var visible: [MetricsChartSeries] { @@ -21,28 +21,55 @@ class MetricsSeriesList: ObservableObject, RandomAccessCollection, RangeReplacea aSeries.visible.toggle() } } - - var foregroundStyles: Dictionary { - var dict = Dictionary() - for aSeries in series { - dict[aSeries.name] = .clear + + func foregroundStyle(forName: String, chartRange: ClosedRange? = nil) -> AnyShapeStyle? where T: BinaryFloatingPoint { + if let selectedSeries = series.first(where: { $0.name == forName }) { + let range = chartRange.map { Float($0.lowerBound)...Float($0.upperBound) } + return selectedSeries.foregroundStyle(range) } - return dict + return nil } - + + func foregroundStyle(forAbbreviatedName: String, chartRange: ClosedRange? = nil) -> AnyShapeStyle? where T: BinaryFloatingPoint { + if let selectedSeries = series.first(where: { $0.abbreviatedName == forAbbreviatedName }) { + let range = chartRange.map { Float($0.lowerBound)...Float($0.upperBound) } + return selectedSeries.foregroundStyle(range) + } + return nil + } + + func chartRange(forData data: [TelemetryEntity]) -> ClosedRange { + var lower: Float? + var upper: Float? + for te in data { + for aSeries in self.visible { + if let value = aSeries.valueFor(te) { + if value > (upper ?? -.infinity) {upper = value} + if value < (lower ?? .infinity) {lower = value} + } + } + } + + // Return default range if no data or nil + guard let lower, let upper else { + return 0.0...100.0 + } + return lower...upper + } + // Collection conformance typealias Index = Int typealias Element = MetricsChartSeries typealias SubSequence = ArraySlice - + required init() { series = [] } required init(_ series: S) where S.Element == Element { self.series = Array(series) } - + var startIndex: Int { series.startIndex } var endIndex: Int { series.endIndex } - + subscript(position: Int) -> Element { get { series[position] } set { @@ -52,31 +79,31 @@ class MetricsSeriesList: ObservableObject, RandomAccessCollection, RangeReplacea } subscript(bounds: Range) -> ArraySlice { series[bounds] } func index(after i: Int) -> Int { series.index(after: i) } - + func replaceSubrange(_ subrange: Range, with newElements: C) where C.Element == Element { objectWillChange.send() series.replaceSubrange(subrange, with: newElements) } - + func append(_ newElement: Element) { series.append(newElement) objectWillChange.send() } - + func remove(at index: Int) -> Element { objectWillChange.send() let removedElement = series.remove(at: index) return removedElement } - + func removeAll() { objectWillChange.send() series.removeAll() } - + func insert(_ newElement: Element, at index: Int) { objectWillChange.send() series.insert(newElement, at: index) } - + } diff --git a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift index 8bda22b1..af626a64 100644 --- a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift +++ b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift @@ -30,18 +30,22 @@ struct EnvironmentMetricsLog: View { let chartData = environmentMetrics .filter { $0.time != nil && $0.time! >= oneWeekAgo! } .sorted { $0.time! < $1.time! } + let chartRange = applyMargins(seriesList.chartRange(forData: chartData)) VStack { if chartData.count > 0 { GroupBox(label: Label("\(environmentMetrics.count) Readings Total", systemImage: "chart.xyaxis.line")) { Chart(seriesList.visible) { series in ForEach(chartData, id: \.time) { dataPoint in - series.body(dataPoint) + series.body(dataPoint, inChartRange: chartRange) } } .chartXAxis(content: { AxisMarks(position: .top) }) - // .chartYScale(domain: format == .celsius ? -20...55 : 0...125) + .chartYScale(domain: chartRange) + .chartForegroundStyleScale { (seriesName: String) -> AnyShapeStyle in + return seriesList.foregroundStyle(forAbbreviatedName: seriesName, chartRange: chartRange) ?? AnyShapeStyle(Color.clear) + } .chartLegend(position: .automatic, alignment: .bottom) } } @@ -175,4 +179,13 @@ struct EnvironmentMetricsLog: View { } ) } + + // Helper. Adds a little buffer to the Y axis range, but keeps Y=0 + func applyMargins(_ range: ClosedRange) -> ClosedRange where T: BinaryFloatingPoint { + let span = range.upperBound - range.lowerBound + let margin = span * 0.1 + let lower = range.lowerBound == 0.0 ? 0.0 : range.lowerBound - margin + let upper = range.upperBound + margin + return lower...upper + } } diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift index 0b6bdf9c..810eaab7 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift @@ -18,37 +18,35 @@ extension MetricsSeriesList { keyPath: \.temperature, name: "Temperature", abbreviatedName: "Temp", - chartBody: { series, time, temperature in + conversion: { Float($0.localeTemperature()) }, + foregroundStyle: { chartRange in + let locale = NSLocale.current as NSLocale + let localeUnit = locale.object(forKey: NSLocale.Key(rawValue: "kCFLocaleTemperatureUnitKey")) + let format: UnitTemperature = localeUnit as? String ?? "Celsius" == "Fahrenheit" ? .fahrenheit : .celsius + let lowerBound = chartRange.map { Double($0.lowerBound) } ?? 0.0 + let upperBound = chartRange.map { Double($0.upperBound) } ?? 100.0 + let stops: [Gradient.Stop] = generateStops(minTemp: lowerBound, maxTemp: upperBound, tempUnit: format, opacity: 1.0) + return LinearGradient(stops: stops, startPoint: .bottom, endPoint: .top) + }, + chartBody: { series, chartRange, time, temperature in AreaMark( x: .value("Time", time), - y: .value( - series.name, temperature.localeTemperature()), - series: .value("Metric", series.name), - stacking: .unstacked - ) - .interpolationMethod(.cardinal) - .foregroundStyle( - .linearGradient( - colors: [.blue, .yellow, .orange, .red, .red], - startPoint: .bottom, endPoint: .top - ) - .opacity(0.6) + yStart: .value(series.abbreviatedName, chartRange?.lowerBound.doubleValue ?? 0.0), + yEnd: .value( + series.abbreviatedName, temperature.localeTemperature()) ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Series", series.abbreviatedName)) .alignsMarkStylesWithPlotArea() .accessibilityHidden(true) + .opacity(0.6) LineMark( x: .value("Time", time), y: .value( - series.name, temperature.localeTemperature()), - series: .value("Metric", series.name) - ) - .interpolationMethod(.cardinal) - .foregroundStyle( - .linearGradient( - colors: [.blue, .yellow, .orange, .red, .red], - startPoint: .bottom, endPoint: .top - ) + series.abbreviatedName, temperature.localeTemperature()) ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Series", series.abbreviatedName)) .lineStyle(StrokeStyle(lineWidth: 4)) .alignsMarkStylesWithPlotArea() }), @@ -58,19 +56,19 @@ extension MetricsSeriesList { keyPath: \.relativeHumidity, name: "Relative Humidity", abbreviatedName: "Hum", - chartBody: { series, time, humidity in + foregroundStyle: { _ in + .linearGradient( + colors: [Color(UIColor.purple.darker(componentDelta: 0.2)), .purple], + startPoint: .bottom, endPoint: .top + ) + }, + chartBody: { series, _, time, humidity in LineMark( x: .value("Time", time), - y: .value(series.name, humidity), - series: .value("Metric", series.name) - ) - .interpolationMethod(.cardinal) - .foregroundStyle( - .linearGradient( - colors: [.gray, .blue], - startPoint: .bottom, endPoint: .top - ) + y: .value(series.abbreviatedName, humidity) ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Series", series.abbreviatedName)) .lineStyle(StrokeStyle(lineWidth: 4)) .alignsMarkStylesWithPlotArea() }), @@ -81,19 +79,19 @@ extension MetricsSeriesList { name: "Barometric Pressure", abbreviatedName: "Bar", visible: false, - chartBody: { series, time, pressure in + foregroundStyle: { _ in + .linearGradient( + colors: [Color(UIColor.green.darker(componentDelta: 0.3)), .green], + startPoint: .bottom, endPoint: .top + ) + }, + chartBody: { series, _, time, pressure in LineMark( x: .value("Time", time), - y: .value(series.name, pressure), - series: .value("Metric", series.name) - ) - .interpolationMethod(.cardinal) - .foregroundStyle( - .linearGradient( - colors: [.gray, .green], - startPoint: .bottom, endPoint: .top - ) + y: .value(series.abbreviatedName, pressure) ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Series", series.abbreviatedName)) .lineStyle(StrokeStyle(lineWidth: 4)) .alignsMarkStylesWithPlotArea() @@ -105,14 +103,23 @@ extension MetricsSeriesList { name: "Indoor Air Quality", abbreviatedName: "IAQ", visible: false, - chartBody: { series, time, iaq in + foregroundStyle: { _ in .gray }, + chartBody: { series, _, time, iaq in let iaqEnum = Iaq.getIaq(for: Int(iaq)) PointMark( x: .value("Time", time), - y: .value(series.name, Float(iaq)) + y: .value(series.abbreviatedName, Float(iaq)) ) .symbol(Circle()) .foregroundStyle(iaqEnum.color) + LineMark( + x: .value("Time", time), + y: .value(series.abbreviatedName, Float(iaq)) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Series", series.abbreviatedName)) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() }), // Combined Wind Speed and Direction Series Configuration -- For use in Chart only @@ -121,30 +128,30 @@ extension MetricsSeriesList { name: "Wind Speed/Direction", abbreviatedName: "Speed/Dir", visible: false, - chartBody: { series, time, wsad in + foregroundStyle: { _ in + .linearGradient( + colors: [Color(UIColor.yellow.darker(componentDelta: 0.3)), Color(UIColor.yellow.darker(componentDelta: 0.1))], + startPoint: .bottom, endPoint: .top + ) + }, + chartBody: { series, _, time, wsad in // debug data: var wsad = WindSpeedAndDirection(windSpeed:Float.random(in:0...25), windDirection: Int32.random(in:0..<3)*90 ) LineMark( x: .value("Time", time), - y: .value(series.name, wsad.windSpeed), - series: .value("Metric", series.name) - ) - .interpolationMethod(.cardinal) - .foregroundStyle( - .linearGradient( - colors: [Color(UIColor.yellow.darker()), .yellow], - startPoint: .bottom, endPoint: .top - ) + y: .value(series.abbreviatedName, wsad.windSpeed) ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Series", series.abbreviatedName)) .lineStyle(StrokeStyle(lineWidth: 4)) .alignsMarkStylesWithPlotArea() PointMark( x: .value("Time", time), - y: .value(series.name, wsad.windSpeed) + y: .value(series.abbreviatedName, wsad.windSpeed) ) .symbol { Image(systemName: "location.north.circle.fill") .symbolRenderingMode(.palette) - .foregroundStyle(Color.white, Color.yellow) + .foregroundStyle(Color.white, Color(UIColor.yellow.darker(componentDelta: 0.3))) .rotationEffect( .degrees(Double(wsad.windDirection))) }.foregroundStyle(.yellow) @@ -155,17 +162,55 @@ extension MetricsSeriesList { // Extension to combine windspeed and direction into one attribute for rendering // for rendering on the chart. -@objc class WindSpeedAndDirection: NSObject { +@objc class WindSpeedAndDirection: NSObject, Plottable, Comparable { + let windSpeed: Float let windDirection: Int32 init(windSpeed: Float, windDirection: Int32) { self.windSpeed = windSpeed self.windDirection = windDirection } + + // Plottable Conformance + required init?(primitivePlottable: Float) { nil } + var primitivePlottable: Float { windSpeed } + + static func < (lhs: WindSpeedAndDirection, rhs: WindSpeedAndDirection) -> Bool { + lhs.windSpeed < rhs.windSpeed + } } + @objc extension TelemetryEntity { var windSpeedAndDirection: WindSpeedAndDirection { return WindSpeedAndDirection( windSpeed: self.windSpeed, windDirection: self.windDirection) } } + +// From: https://github.com/meshtastic/Meshtastic-Apple/pull/1013/commits/bc932567c742c8fa9fd30752237b10cb762c5ef3 +// Set up gradient stops relative to the scale of the temperature chart +func generateStops(minTemp: Double, maxTemp: Double, tempUnit: UnitTemperature, opacity: Double) -> [Gradient.Stop] { + var gradientStops = [Gradient.Stop]() + + let stopTargets: [(Double, Color)] = [ + ((tempUnit == .celsius ? 0 : 32), .blue), + ((tempUnit == .celsius ? 20 : 68), .yellow), + ((tempUnit == .celsius ? 30 : 86), .orange), + ((tempUnit == .celsius ? 55 : 125), .red) + ] + for (stopValue, color) in stopTargets { + let stopLocation = transform(stopValue, from: minTemp...maxTemp, to: 0...1) + gradientStops.append(Gradient.Stop(color: color.opacity(opacity), location: stopLocation)) + } + return gradientStops +} + +// Map inputRange to outputRange +func transform(_ input: T, from inputRange: ClosedRange, to outputRange: ClosedRange) -> T { + // need to determine what that value would be in (to.low, to.high) + // difference in output range / difference in input range = slope + let slope = (outputRange.upperBound - outputRange.lowerBound) / (inputRange.upperBound - inputRange.lowerBound) + // slope * normalized input + output lower + let output = slope * (input - inputRange.lowerBound) + outputRange.lowerBound + return output +} diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift index 6f58a077..a2bcd9f8 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift @@ -18,6 +18,9 @@ struct MetricsColumnDetail: View { Section("Chart") { ForEach(seriesList) { series in HStack { + Circle() + .fill(series.foregroundStyle(0.0...100.0) ?? AnyShapeStyle(.clear)) + .frame(width: 20.0, height: 20.0) Text(series.name) Spacer() if series.visible { @@ -26,8 +29,7 @@ struct MetricsColumnDetail: View { } }.contentShape(Rectangle()) // Ensures the entire row is tappable .onTapGesture { - seriesList.objectWillChange.send() - series.visible.toggle() + seriesList.toggleVisibity(for: series) } } } @@ -43,7 +45,7 @@ struct MetricsColumnDetail: View { }.contentShape(Rectangle()) // Ensures the entire row is tappable .onTapGesture { columnList.objectWillChange.send() - column.visible.toggle() + columnList.toggleVisibity(for: column) } } } From 4e57daaf8f145f2eadc420c828c54c1ee9aea643 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Fri, 13 Dec 2024 15:54:25 -0500 Subject: [PATCH 06/14] Improvements for iPad and Mac Catalyst --- Localizable.xcstrings | 6 +- .../MetricsColumnList.swift | 6 +- .../Views/Nodes/EnvironmentMetricsLog.swift | 25 +++--- .../EnvironmentDefaultColumns.swift | 21 +++-- .../Metrics Columns/MetricsColumnDetail.swift | 85 ++++++++++++------- 5 files changed, 85 insertions(+), 58 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index e8957a07..6f513769 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -132,9 +132,6 @@ }, "%@ dB" : { - }, - "%@ hPa" : { - }, "%@, %@" : { "localizations" : { @@ -6956,6 +6953,9 @@ }, "Documentation" : { + }, + "Done" : { + }, "Double Tap as Button" : { diff --git a/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift b/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift index cd843d6d..0476b6b8 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift @@ -42,7 +42,11 @@ class MetricsColumnList: ObservableObject, RandomAccessCollection, RangeReplacea } return returnValues } - + + func column(forAttribute attribute: String) -> MetricsTableColumn? { + return columns.first(where: { $0.attribute == attribute}) + } + // Collection conformance typealias Index = Int typealias Element = MetricsTableColumn diff --git a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift index af626a64..3b88d0bd 100644 --- a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift +++ b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift @@ -49,36 +49,32 @@ struct EnvironmentMetricsLog: View { .chartLegend(position: .automatic, alignment: .bottom) } } - let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current) - let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "") + + // Dynamic table column using SwiftUI Table requires TableColumnForEach which requires the target + // to be bumped to 17.4 -- Until that happens, the existing non-configurable table is used. if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { // Add a table for mac and ipad Table(environmentMetrics) { TableColumn("Temperature") { em in - Text(em.temperature.formattedTemperature()) + columnList.column(forAttribute: "temperature")?.body(em) } TableColumn("Humidity") { em in - Text("\(String(format: "%.0f", em.relativeHumidity))%") + columnList.column(forAttribute: "relativeHumidity")?.body(em) } TableColumn("Barometric Pressure") { em in - Text("\(String(format: "%.1f", em.barometricPressure)) hPa") + columnList.column(forAttribute: "barometricPressure")?.body(em) } TableColumn("Indoor Air Quality") { em in - HStack { - Text("IAQ") - IndoorAirQuality(iaq: Int(em.iaq), displayMode: IaqDisplayMode.dot ) - } + columnList.column(forAttribute: "iaq")?.body(em) } TableColumn("Wind Speed") { em in - let windSpeed = Measurement(value: Double(em.windSpeed), unit: UnitSpeed.kilometersPerHour) - Text(windSpeed.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0))))) + columnList.column(forAttribute: "windSpeed")?.body(em) } TableColumn("Wind Direction") { em in - let direction = cardinalValue(from: Double(em.windDirection)) - Text(direction) + columnList.column(forAttribute: "windDirection")?.body(em) } TableColumn("timestamp") { em in - Text(em.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized) + columnList.column(forAttribute: "time")?.body(em) } .width(min: 180) } @@ -96,6 +92,7 @@ struct EnvironmentMetricsLog: View { GridRow { ForEach(columnList.visible) { col in col.body(em) + .font(.caption) } } } diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift index 0821330f..4f1e2b5f 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift @@ -21,7 +21,6 @@ extension MetricsColumnList { minWidth: 25, maxWidth: 40, tableBody: { _, temp in Text(temp.formattedTemperature()) - .font(.caption) }), // Relative Humidity Series Configuration @@ -32,7 +31,6 @@ extension MetricsColumnList { minWidth: 25, maxWidth: 40, tableBody: { _, humidity in Text("\(String(format: "%.0f", humidity))%") - .font(.caption) }), // Barometric Pressure Series Configuration @@ -42,8 +40,11 @@ extension MetricsColumnList { abbreviatedName: "Bar", minWidth: 30, maxWidth: 50, tableBody: { _, pressure in - Text("\(String(format: "%.1f", pressure))") - .font(.caption) + if (UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) { + Text("\(String(format: "%.1f hPa", pressure))") + } else { + Text("\(String(format: "%.1f", pressure))") + } }), // Indoor Air Quality Series Configuration @@ -54,7 +55,6 @@ extension MetricsColumnList { minWidth: 25, maxWidth: 50, tableBody: { _, iaq in IndoorAirQuality(iaq: Int(iaq), displayMode: .dot) - .font(.caption) }), // Wind Direction Series Configuration @@ -70,9 +70,14 @@ extension MetricsColumnList { let wind = Double(wind) Image(systemName: "location.north") .imageScale(.small) + .scaleEffect(0.9, anchor: .center) .rotationEffect(.degrees(wind)) - Text(abbreviatedCardinalValue(from: wind)) - .font(.caption) + .foregroundStyle(.blue) + if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { + Text(cardinalValue(from: wind)) + } else { + Text(abbreviatedCardinalValue(from: wind)) + } } }), @@ -93,7 +98,6 @@ extension MetricsColumnList { numberFormatStyle: .number.precision( .fractionLength(0)))) ) - .font(.caption) }), // Timestamp Series Configuration -- for use in table only @@ -113,7 +117,6 @@ extension MetricsColumnList { time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized ) - .font(.caption) }) ]) } diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift index a2bcd9f8..b99bb37f 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift @@ -13,46 +13,69 @@ struct MetricsColumnDetail: View { @State private var currentDetent = PresentationDetent.medium + @Environment(\.dismiss) private var dismiss + var body: some View { - List { - Section("Chart") { - ForEach(seriesList) { series in - HStack { - Circle() - .fill(series.foregroundStyle(0.0...100.0) ?? AnyShapeStyle(.clear)) - .frame(width: 20.0, height: 20.0) - Text(series.name) - Spacer() - if series.visible { - Image(systemName: "checkmark") - .foregroundColor(.blue) - } - }.contentShape(Rectangle()) // Ensures the entire row is tappable - .onTapGesture { - seriesList.toggleVisibity(for: series) + ZStack { + List { + Section("Chart") { + ForEach(seriesList) { series in + HStack { + Circle() + .fill(series.foregroundStyle(0.0...100.0) ?? AnyShapeStyle(.clear)) + .frame(width: 20.0, height: 20.0) + Text(series.name) + Spacer() + if series.visible { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + }.contentShape(Rectangle()) // Ensures the entire row is tappable + .onTapGesture { + seriesList.toggleVisibity(for: series) + } + } + } + // Dynamic table column using SwiftUI Table requires TableColumnForEach which requires the target + // to be bumped to 17.4 -- Until that happens, the existing non-configurable table is used. + if !(UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) { + Section("Table") { + ForEach(columnList.columns) { column in + HStack { + Text(column.name) + Spacer() + if column.visible { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + }.contentShape(Rectangle()) // Ensures the entire row is tappable + .onTapGesture { + columnList.objectWillChange.send() + columnList.toggleVisibity(for: column) + } } + } } } - Section("Table") { - ForEach(columnList.columns) { column in - HStack { - Text(column.name) - Spacer() - if column.visible { - Image(systemName: "checkmark") - .foregroundColor(.blue) - } - }.contentShape(Rectangle()) // Ensures the entire row is tappable - .onTapGesture { - columnList.objectWillChange.send() - columnList.toggleVisibity(for: column) - } + + // More friendly to tap a button to dismiss on these devices + if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { + Spacer() + Button { + self.dismiss() + } label: { + Text("Done") } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding([.leading, .trailing, .bottom]) } } .presentationDetents([.medium, .large], selection: $currentDetent) .presentationContentInteraction(.scrolls) - .presentationDragIndicator(.visible) + .presentationDragIndicator( + !(UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) ? .visible : .hidden) .presentationBackgroundInteraction(.enabled(upThrough: .medium)) .interactiveDismissDisabled(false) } From 11b69505c150f860e63d90e12af9e6f68ba96a20 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Sat, 14 Dec 2024 18:10:24 -0500 Subject: [PATCH 07/14] Removed commented-out/unused code. --- .../Metrics Visualization/MetricsChartSeries.swift | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift index 0766ae72..add0318e 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift @@ -69,16 +69,6 @@ class MetricsChartSeries: ObservableObject { } } -// // Return the maximum value for this series attribute given the data -// func max(forData: [TelemetryEntity]) -> Float? { -// return forData.compactMap { self.valueClosure($0) }.max() -// } -// -// // Return the minimum value for this series attribute given the data -// func min(forData: [TelemetryEntity]) -> Float? { -// return forData.compactMap { self.valueClosure($0) }.min() -// } -// // Return the value for this series attribute given a full row of telemetry data func valueFor(_ te: TelemetryEntity) -> Float? { return self.valueClosure(te)?.floatValue From 302f6789c53561b2bed1b5f328c0fad8a93caae7 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sat, 21 Dec 2024 05:48:17 -0800 Subject: [PATCH 08/14] Bump version for testflight --- Meshtastic.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 13eec2e1..8877bfa9 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -1754,7 +1754,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.13; + MARKETING_VERSION = 2.5.14; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1788,7 +1788,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.13; + MARKETING_VERSION = 2.5.14; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1820,7 +1820,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.5.13; + MARKETING_VERSION = 2.5.14; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1853,7 +1853,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.5.13; + MARKETING_VERSION = 2.5.14; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From 57ba7af7d88b04350bfd4b0c815a026f69f33400 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sat, 21 Dec 2024 09:56:34 -0800 Subject: [PATCH 09/14] Add ignore node button to node details, make button deestructive --- Localizable.xcstrings | 1 + Meshtastic.xcodeproj/project.pbxproj | 8 ++++---- .../Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift | 2 +- Meshtastic/Views/Nodes/Helpers/NodeDetail.swift | 5 +++++ 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index ca7d895e..91e83b8c 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -2390,6 +2390,7 @@ } }, "Bad" : { + "extractionState" : "stale", "localizations" : { "sr" : { "stringUnit" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index d767a83c..17808038 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -1710,7 +1710,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.13; + MARKETING_VERSION = 2.5.14; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1744,7 +1744,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.13; + MARKETING_VERSION = 2.5.14; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1776,7 +1776,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.5.13; + MARKETING_VERSION = 2.5.14; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1809,7 +1809,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.5.13; + MARKETING_VERSION = 2.5.14; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift index 0ec05164..84fdf4d3 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift @@ -10,7 +10,7 @@ struct IgnoreNodeButton: View { var node: NodeInfoEntity var body: some View { - Button { + Button(role: .destructive) { guard let connectedNodeNum = bleManager.connectedPeripheral?.num else { return } let success = if node.ignored { bleManager.removeIgnoredNode( diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index d9fc50a4..1f21fdd7 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -357,6 +357,11 @@ struct NodeDetail: View { node: node ) } + IgnoreNodeButton( + bleManager: bleManager, + context: context, + node: node + ) DeleteNodeButton( bleManager: bleManager, context: context, From 8d374bf0344ba677f62a20a58ffc21c31a5e7bec Mon Sep 17 00:00:00 2001 From: Jake-B Date: Sat, 21 Dec 2024 18:20:55 -0500 Subject: [PATCH 10/14] Adjusted Environment Metrics column widths --- .../Metrics Columns/EnvironmentDefaultColumns.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift index 4f1e2b5f..7df0dbd3 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift @@ -18,7 +18,7 @@ extension MetricsColumnList { keyPath: \.temperature, name: "Temperature", abbreviatedName: "Temp", - minWidth: 25, maxWidth: 40, + minWidth: 30, maxWidth: 45, tableBody: { _, temp in Text(temp.formattedTemperature()) }), @@ -28,7 +28,7 @@ extension MetricsColumnList { keyPath: \.relativeHumidity, name: "Relative Humidity", abbreviatedName: "Hum", - minWidth: 25, maxWidth: 40, + minWidth: 30, maxWidth: 45, tableBody: { _, humidity in Text("\(String(format: "%.0f", humidity))%") }), @@ -52,7 +52,7 @@ extension MetricsColumnList { keyPath: \.iaq, name: "Indoor Air Quality", abbreviatedName: "IAQ", - minWidth: 25, maxWidth: 50, + minWidth: 30, maxWidth: 50, tableBody: { _, iaq in IndoorAirQuality(iaq: Int(iaq), displayMode: .dot) }), @@ -86,7 +86,7 @@ extension MetricsColumnList { keyPath: \.windSpeed, name: "Wind Speed", abbreviatedName: "Wind", - minWidth: 30, maxWidth: 40, + minWidth: 30, maxWidth: 60, visible: false, tableBody: { _, speed in let windSpeed = Measurement( From c25450898360724cff229ffc312882ac50a77955 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Sat, 21 Dec 2024 18:38:27 -0500 Subject: [PATCH 11/14] Smaller buttons on phones for Environment Metrics --- .../Views/Nodes/EnvironmentMetricsLog.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift index 3b88d0bd..205fe9d9 100644 --- a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift +++ b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift @@ -103,13 +103,18 @@ struct EnvironmentMetricsLog: View { } } HStack { + let isPadOrCatalyst = UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac + let buttonSize: ControlSize = isPadOrCatalyst ? .large : .small + let imageScale: Image.Scale = isPadOrCatalyst ? .medium : .small Button { self.isEditingColumnConfiguration = true } label: { Label("Config", systemImage: "gearshape") - } .buttonStyle(.bordered) + .imageScale(imageScale) + } + .buttonStyle(.bordered) .buttonBorderShape(.capsule) - .controlSize(.large) + .controlSize(buttonSize) .padding(.bottom) .padding(.leading) .sheet(isPresented: self.$isEditingColumnConfiguration) { @@ -119,12 +124,12 @@ struct EnvironmentMetricsLog: View { isPresentingClearLogConfirm = true } label: { Label("clear.log", systemImage: "trash.fill") + .imageScale(imageScale) } .buttonStyle(.bordered) .buttonBorderShape(.capsule) - .controlSize(.large) + .controlSize(buttonSize) .padding(.bottom) - .padding(.leading) .confirmationDialog( "are.you.sure", isPresented: $isPresentingClearLogConfirm, @@ -141,10 +146,11 @@ struct EnvironmentMetricsLog: View { isExporting = true } label: { Label("save", systemImage: "square.and.arrow.down") + .imageScale(imageScale) } .buttonStyle(.bordered) .buttonBorderShape(.capsule) - .controlSize(.large) + .controlSize(buttonSize) .padding(.bottom) .padding(.trailing) } From 4f807419f6673d05858cd6145f72e4bbf74c83d8 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Sat, 21 Dec 2024 18:59:36 -0500 Subject: [PATCH 12/14] Consistency tweaks for Catalyst --- .../Metrics Columns/MetricsColumnDetail.swift | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift index b99bb37f..1f384cb2 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift @@ -16,8 +16,8 @@ struct MetricsColumnDetail: View { @Environment(\.dismiss) private var dismiss var body: some View { - ZStack { - List { + NavigationStack { + Form { Section("Chart") { ForEach(seriesList) { series in HStack { @@ -57,25 +57,23 @@ struct MetricsColumnDetail: View { } } } - - // More friendly to tap a button to dismiss on these devices - if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { - Spacer() - Button { - self.dismiss() - } label: { - Text("Done") - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding([.leading, .trailing, .bottom]) + .listStyle(.insetGrouped) +#if targetEnvironment(macCatalyst) + Spacer() + Button { + dismiss() + } label: { + Label("close", systemImage: "xmark") } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) +#endif } .presentationDetents([.medium, .large], selection: $currentDetent) .presentationContentInteraction(.scrolls) - .presentationDragIndicator( - !(UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) ? .visible : .hidden) + .presentationDragIndicator(.visible) .presentationBackgroundInteraction(.enabled(upThrough: .medium)) .interactiveDismissDisabled(false) } From d5e0ee776b1966323f0cfa74fb1476fc98302ef5 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Sat, 21 Dec 2024 21:21:42 -0500 Subject: [PATCH 13/14] Changed button icon to `tablecells` --- Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift index 205fe9d9..b7d5449c 100644 --- a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift +++ b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift @@ -109,7 +109,7 @@ struct EnvironmentMetricsLog: View { Button { self.isEditingColumnConfiguration = true } label: { - Label("Config", systemImage: "gearshape") + Label("Config", systemImage: "tablecells") .imageScale(imageScale) } .buttonStyle(.bordered) From e4ba47abc2b3a2a814564b69df5e33c8d72028c0 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 22 Dec 2024 09:49:56 -0800 Subject: [PATCH 14/14] Reciprical extension for measurement angles (headings) --- Localizable.xcstrings | 6 ++---- Meshtastic/Extensions/Measurement.swift | 8 ++++++++ Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift | 2 +- Meshtastic/Views/Nodes/Helpers/NodeListItem.swift | 2 +- Meshtastic/Views/Nodes/PositionLog.swift | 2 +- Meshtastic/Views/Settings/GPSStatus.swift | 4 ++-- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 94e27bac..a5e01356 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -297,6 +297,7 @@ } }, "%@ hPa" : { + "extractionState" : "stale", "localizations" : { "sr" : { "stringUnit" : { @@ -9252,9 +9253,6 @@ } } } - }, - "Done" : { - }, "Double Tap as Button" : { "localizations" : { @@ -29835,8 +29833,8 @@ } } }, - "Trace route received directly by %@ with a SNR of %@ dB" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/Meshtastic/Extensions/Measurement.swift b/Meshtastic/Extensions/Measurement.swift index ca867c09..4947c823 100644 --- a/Meshtastic/Extensions/Measurement.swift +++ b/Meshtastic/Extensions/Measurement.swift @@ -8,6 +8,14 @@ import Foundation import Charts +extension Measurement where UnitType == UnitAngle { + func reciprocal() -> Measurement { + var recip = self.converted(to: .degrees) + recip.value = (recip.value + 180).truncatingRemainder(dividingBy: 360) + return recip.converted(to: self.unit) + } +} + struct PlottableMeasurement { var measurement: Measurement } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift index fb019e0b..8874ba98 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift @@ -143,7 +143,7 @@ struct PositionPopover: View { /// Heading let degrees = Angle.degrees(Double(position.heading)) Label { - let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees) + let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees).reciprocal() Text("Heading: \(heading.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0)))))") } icon: { Image(systemName: "location.north") diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift index b75d5822..2693f13e 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift @@ -120,7 +120,7 @@ struct NodeListItem: View { .symbolRenderingMode(.multicolor) .clipShape(Circle()) .rotationEffect(headingDegrees) - let heading = Measurement(value: trueBearing, unit: UnitAngle.degrees) + let heading = Measurement(value: trueBearing, unit: UnitAngle.degrees).reciprocal() Text("\(heading.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0)))))") .font(UIDevice.current.userInterfaceIdiom == .phone ? .callout : .caption) .foregroundColor(.gray) diff --git a/Meshtastic/Views/Nodes/PositionLog.swift b/Meshtastic/Views/Nodes/PositionLog.swift index 3abcb791..ee2709db 100644 --- a/Meshtastic/Views/Nodes/PositionLog.swift +++ b/Meshtastic/Views/Nodes/PositionLog.swift @@ -52,7 +52,7 @@ struct PositionLog: View { } TableColumn("Heading") { position in let degrees = Angle.degrees(Double(position.heading)) - let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees) + let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees).reciprocal() Text(heading.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0))))) } TableColumn("SNR") { position in diff --git a/Meshtastic/Views/Settings/GPSStatus.swift b/Meshtastic/Views/Settings/GPSStatus.swift index c92a647c..7e0a6588 100644 --- a/Meshtastic/Views/Settings/GPSStatus.swift +++ b/Meshtastic/Views/Settings/GPSStatus.swift @@ -22,7 +22,7 @@ struct GPSStatus: View { let altitiude = Measurement(value: newLocation.altitude, unit: UnitLength.meters) let speed = Measurement(value: newLocation.speed, unit: UnitSpeed.kilometersPerHour) let speedAccuracy = Measurement(value: newLocation.speedAccuracy, unit: UnitSpeed.metersPerSecond) - let courseAccuracy = Measurement(value: newLocation.courseAccuracy, unit: UnitAngle.degrees) + let courseAccuracy = Measurement(value: newLocation.courseAccuracy, unit: UnitAngle.degrees).reciprocal() Label("Coordinate \(String(format: "%.5f", newLocation.coordinate.latitude)), \(String(format: "%.5f", newLocation.coordinate.longitude))", systemImage: "mappin") .font(largeFont) @@ -45,7 +45,7 @@ struct GPSStatus: View { HStack { let degrees = Angle.degrees(newLocation.course) Label { - let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees) + let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees).reciprocal() Text("Heading: \(heading.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0)))))") } icon: { Image(systemName: "location.north")