Additional refinements to configurable columns and charts

This commit is contained in:
Jake-B 2024-12-13 08:01:09 -05:00
parent a12d5584aa
commit 76dfc9647e
8 changed files with 276 additions and 109 deletions

View file

@ -20613,6 +20613,9 @@
}
}
}
},
"Series" : {
},
"Server" : {
"localizations" : {

View file

@ -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<Value, TableContent: View>(

View file

@ -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<Float>?) -> AnyShapeStyle?
// A closure that will provide the Chart Content for this series
let chartBodyClosure:
(MetricsChartSeries, TelemetryEntity) -> AnyChartContent? // Closure to render the chart
(MetricsChartSeries, ClosedRange<Float>?, 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<Value, ChartBody: ChartContent>(
init<Value, ChartBody: ChartContent, ForegroundStyle: ShapeStyle>(
keyPath: KeyPath<TelemetryEntity, Value>,
name: String,
abbreviatedName: String,
conversion: ((Value) -> Value)? = nil,
visible: Bool = true,
@ChartContentBuilder chartBody: @escaping (MetricsChartSeries, Date, Value) -> ChartBody?
) {
foregroundStyle: @escaping ((ClosedRange<Float>?) -> ForegroundStyle?) = { _ in nil },
@ChartContentBuilder chartBody: @escaping (MetricsChartSeries, ClosedRange<Float>?, 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<T>(_ te: TelemetryEntity, inChartRange chartRange: ClosedRange<T>? = 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
}
}

View file

@ -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<Element>
required init() { columns = [] }
required init<S: Sequence>(_ 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<Int>) -> ArraySlice<Element> { columns[bounds] }
func index(after i: Int) -> Int { columns.index(after: i) }
func replaceSubrange<C: Collection>(_ subrange: Range<Int>, 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)

View file

@ -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<String,Color> {
var dict = Dictionary<String,Color>()
for aSeries in series {
dict[aSeries.name] = .clear
func foregroundStyle<T>(forName: String, chartRange: ClosedRange<T>? = 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<T>(forAbbreviatedName: String, chartRange: ClosedRange<T>? = 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<Float> {
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<Element>
required init() { series = [] }
required init<S: Sequence>(_ 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<Int>) -> ArraySlice<Element> { series[bounds] }
func index(after i: Int) -> Int { series.index(after: i) }
func replaceSubrange<C: Collection>(_ subrange: Range<Int>, 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)
}
}

View file

@ -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<T>(_ range: ClosedRange<T>) -> ClosedRange<T> 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
}
}

View file

@ -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<T: FloatingPoint>(_ input: T, from inputRange: ClosedRange<T>, to outputRange: ClosedRange<T>) -> 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
}

View file

@ -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)
}
}
}