Refinements to configurable metrics chart and table

This commit is contained in:
Jake-B 2024-12-11 20:44:44 -05:00
parent 7ef92bb29b
commit a12d5584aa
13 changed files with 678 additions and 371 deletions

View file

@ -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 = "<group>"; };
231B3F202D087A4C0069A07D /* MetricColumnConfigurationEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricColumnConfigurationEntry.swift; sourceTree = "<group>"; };
231B3F242D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentMetricsColumnDefaults.swift; sourceTree = "<group>"; };
231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = "<group>"; };
231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = "<group>"; };
231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = "<group>"; };
231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = "<group>"; };
2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = "<group>"; };
2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = "<group>"; };
2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnviornmentDefaultSeries.swift; sourceTree = "<group>"; };
251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteNodeButton.swift; sourceTree = "<group>"; };
251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeAlertsButton.swift; sourceTree = "<group>"; };
251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangePositionsButton.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
};
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 */,

View file

@ -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<Value, TableContent: View, ChartAxes: ChartContent>(attribute: String, keyPath: KeyPath<TelemetryEntity, Value>,
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)
}
}

View file

@ -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..<columnsInChart.count {
let thisColumn = columnsInChart[i]
let spacing = (i == columns.count - 1) ? 0 : thisColumn.spacing
if let min = thisColumn.minWidth, let max = thisColumn.maxWidth {
returnValues.append(GridItem(.flexible(minimum: min, maximum: max), spacing: spacing))
} else {
returnValues.append(GridItem(.flexible(), spacing: spacing))
}
}
return returnValues
}
}

View file

@ -0,0 +1,72 @@
//
// SeriesConfigurationEntry.swift
// Meshtastic
//
// Created by Jake Bordens on 12/7/24.
//
import Charts
import OSLog
import SwiftUI
// MetricsTableColumn stores metadata about an attribute in TelemetryEntity.
// 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 {
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
// Main initializer
init<Value, TableContent: View>(
keyPath: KeyPath<TelemetryEntity, Value>,
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)
}
}

View file

@ -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<Value, ChartBody: ChartContent>(
keyPath: KeyPath<TelemetryEntity, Value>,
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)
}
}

View file

@ -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..<columnsInChart.count {
let thisColumn = columnsInChart[i]
let spacing = (i == columns.count - 1) ? 0 : thisColumn.spacing
if let min = thisColumn.minWidth, let max = thisColumn.maxWidth {
returnValues.append(
GridItem(
.flexible(minimum: min, maximum: max), spacing: spacing)
)
} else {
returnValues.append(GridItem(.flexible(), spacing: spacing))
}
}
return returnValues
}
// Collection conformance
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 {
objectWillChange.send()
columns[position] = newValue
}
}
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

@ -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<String,Color> {
var dict = Dictionary<String,Color>()
for aSeries in series {
dict[aSeries.name] = .clear
}
return dict
}
// 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 {
objectWillChange.send()
series[position] = newValue
}
}
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

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

View file

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

View file

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

View file

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

View file

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

View file

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