From 76dfc9647e685e6b1e90643ad9c0b2245936dc44 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Fri, 13 Dec 2024 08:01:09 -0500 Subject: [PATCH] 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) } } }