From 7ef92bb29b9332192a7924ad7fa88eb191c882cd Mon Sep 17 00:00:00 2001 From: Jake-B Date: Tue, 10 Dec 2024 18:43:46 -0500 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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