diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 60c6e50b..fadb81fe 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -17,7 +17,7 @@ 2344A2B12D68DFF800170A77 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C49D8F2C471AEA0024FBD1 /* Constants.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 */; }; + 2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.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 */; }; @@ -282,7 +282,7 @@ 2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataProperties.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 = ""; }; + 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultSeries.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 = ""; }; @@ -600,7 +600,7 @@ isa = PBXGroup; children = ( 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */, - 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */, + 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */, 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */, ); path = "Metrics Columns"; @@ -1405,7 +1405,7 @@ DDB6ABD628AE742000384BA1 /* BluetoothConfig.swift in Sources */, 251926902C3CB44900249DF5 /* ClientHistoryButton.swift in Sources */, DDD5BB102C285FB3007E03CA /* AppLogFilter.swift in Sources */, - 2373AE172D0A26620086C749 /* EnviornmentDefaultSeries.swift in Sources */, + 2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */, DD4640202AFF10F4002A5ECB /* WaypointForm.swift in Sources */, DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */, DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */, diff --git a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift index b535099e..ab68c4cc 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift @@ -38,12 +38,18 @@ class MetricsChartSeries: ObservableObject { // Possibly converted to the proper units let valueClosure: (TelemetryEntity) -> Float? + // Used for scaling the Y-axis + let initialYAxisRange: ClosedRange? + let minumumYAxisSpan: Float? + // Main initializer init( id: String, keyPath: KeyPath, name: String, abbreviatedName: String, + initialYAxisRange: ClosedRange? = nil, + minumumYAxisSpan: Float? = nil, conversion: ((Value) -> Value)? = nil, visible: Bool = true, foregroundStyle: @escaping ((ClosedRange?) -> ForegroundStyle?) = { _ in nil }, @@ -54,6 +60,8 @@ class MetricsChartSeries: ObservableObject { self.id = id self.name = name self.abbreviatedName = abbreviatedName + self.initialYAxisRange = initialYAxisRange + self.minumumYAxisSpan = minumumYAxisSpan self.visible = visible // By saving these closures, MetricsChartSeries can be type agnostic diff --git a/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift b/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift index 049d1fb4..46ec6258 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI + class MetricsSeriesList: ObservableObject, RandomAccessCollection, RangeReplaceableCollection { @Published var series: [MetricsChartSeries] @@ -38,25 +39,86 @@ class MetricsSeriesList: ObservableObject, RandomAccessCollection, RangeReplacea return nil } + // Calculates the chartRange based on the series configuration and data provided + // Besides checkign the range of the data, this function also obeys some series-level + // configuraiton, such as: + // 1. starting with a desired fixed range + // 2. obeying a minimum span func chartRange(forData data: [TelemetryEntity]) -> ClosedRange { - var lower: Float? - var upper: Float? + var globalLower: Float = .infinity + var globalUpper: Float = -.infinity + + // Keep track of the range of each series + var range: [MetricsChartSeries: ClosedRange] = [:] + + // Determine if there is an initial fixed range. + // The range might exapand past this initial range if the data goes beyond. + for aSeries in self.visible { + if let thisRange = aSeries.initialYAxisRange { + range[aSeries] = thisRange + if thisRange.upperBound > globalUpper {globalUpper = thisRange.upperBound} + if thisRange.lowerBound < globalLower {globalLower = thisRange.lowerBound} + } + } + + // Iterate through all the data. It would be easier to iterate + // the series then the data, but this way we only iterate the data once for te in data { for aSeries in self.visible { + var seriesUpper = range[aSeries]?.upperBound ?? -.infinity + var seriesLower = range[aSeries]?.lowerBound ?? .infinity + if let value = aSeries.valueFor(te) { - if value > (upper ?? -.infinity) {upper = value} - if value < (lower ?? .infinity) {lower = value} + // Update the global bounds + if value > globalUpper {globalUpper = value} + if value < globalLower {globalLower = value} + + // Update the series bounds if necessary + if value > seriesUpper || value < seriesLower { + if value > seriesUpper { + seriesUpper = value + } + if value < seriesLower { + seriesLower = value + } + if seriesUpper.isFinite && seriesLower.isFinite { + range[aSeries] = seriesLower...seriesUpper + } + } } } } - - // Return default range if no data or nil - guard let lower, let upper else { + + // Go through each series one last time to obey the minimum span + for aSeries in self.visible { + if let minimumSpan = aSeries.minumumYAxisSpan, + let currentRange = range[aSeries] { + let currentSpan = currentRange.upperBound - currentRange.lowerBound + //Logger.data.info("Updated \(aSeries.id) to \(range[aSeries] ?? 0...0) span=\(currentSpan)") + if currentSpan < minimumSpan { + // Calculate the center of the range + let centerOfRange = currentRange.lowerBound + (currentSpan / 2) + let newLower = centerOfRange - (minimumSpan / 2.0) + let newUpper = centerOfRange + (minimumSpan / 2.0) + + if newUpper > globalUpper { + globalUpper = newUpper + } + if newLower < globalLower { + globalLower = newLower + } + } + } + } + + // Return default range if no data + if !globalLower.isFinite || !globalUpper.isFinite { return 0.0...100.0 } - return lower...upper + return globalLower...globalUpper } + // Collection conformance typealias Index = Int typealias Element = MetricsChartSeries diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultSeries.swift similarity index 99% rename from Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift rename to Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultSeries.swift index 8150f971..e9422ee4 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultSeries.swift @@ -19,6 +19,7 @@ extension MetricsSeriesList { keyPath: \.temperature, name: "Temperature", abbreviatedName: "Temp", + minumumYAxisSpan: 50.0, conversion: { t in t.map { Float($0.localeTemperature()) } }, foregroundStyle: { chartRange in let locale = NSLocale.current as NSLocale @@ -60,6 +61,7 @@ extension MetricsSeriesList { keyPath: \.relativeHumidity, name: "Relative Humidity", abbreviatedName: "Hum", + initialYAxisRange: 0.0...100.0, foregroundStyle: { _ in .linearGradient( colors: [Color(UIColor.purple.darker(componentDelta: 0.2)), .purple],