Skip to content

Commit d02732d

Browse files
authored
Merge pull request #1016 from jake-b/configurable_columns
Configurable table columns and chart series for the Environment Metrics Log
2 parents cd7093b + 78a9cdf commit d02732d

11 files changed

+965
-87
lines changed

Localizable.xcstrings

+18
Original file line numberDiff line numberDiff line change
@@ -4517,6 +4517,9 @@
45174517
}
45184518
}
45194519
}
4520+
},
4521+
"Chart" : {
4522+
45204523
},
45214524
"CHG" : {
45224525
"localizations" : {
@@ -4869,6 +4872,9 @@
48694872
}
48704873
}
48714874
}
4875+
},
4876+
"Config" : {
4877+
48724878
},
48734879
"config.module.paxcounter.enabled.description" : {
48744880
"localizations" : {
@@ -9245,6 +9251,9 @@
92459251
}
92469252
}
92479253
}
9254+
},
9255+
"Done" : {
9256+
92489257
},
92499258
"Double Tap as Button" : {
92509259
"localizations" : {
@@ -19348,6 +19357,9 @@
1934819357
}
1934919358
}
1935019359
}
19360+
},
19361+
"Metric" : {
19362+
1935119363
},
1935219364
"Minimum Distance" : {
1935319365
"localizations" : {
@@ -26503,6 +26515,9 @@
2650326515
}
2650426516
}
2650526517
}
26518+
},
26519+
"Series" : {
26520+
2650626521
},
2650726522
"Server" : {
2650826523
"localizations" : {
@@ -27718,6 +27733,9 @@
2771827733
}
2771927734
}
2772027735
}
27736+
},
27737+
"Table" : {
27738+
2772127739
},
2772227740
"tapback" : {
2772327741
"localizations" : {

Meshtastic.xcodeproj/project.pbxproj

+44
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */; };
11+
231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */; };
12+
231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */; };
13+
231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */; };
14+
2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */; };
15+
2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */; };
16+
2373AE172D0A26620086C749 /* EnviornmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */; };
1017
251926852C3BA97800249DF5 /* FavoriteNodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */; };
1118
251926872C3BAE2200249DF5 /* NodeAlertsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */; };
1219
2519268A2C3BB1B200249DF5 /* ExchangePositionsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */; };
@@ -259,6 +266,13 @@
259266
/* End PBXCopyFilesBuildPhase section */
260267

261268
/* Begin PBXFileReference section */
269+
231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = "<group>"; };
270+
231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = "<group>"; };
271+
231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = "<group>"; };
272+
231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = "<group>"; };
273+
2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = "<group>"; };
274+
2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = "<group>"; };
275+
2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnviornmentDefaultSeries.swift; sourceTree = "<group>"; };
262276
251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteNodeButton.swift; sourceTree = "<group>"; };
263277
251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeAlertsButton.swift; sourceTree = "<group>"; };
264278
251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangePositionsButton.swift; sourceTree = "<group>"; };
@@ -555,6 +569,27 @@
555569
/* End PBXFrameworksBuildPhase section */
556570

557571
/* Begin PBXGroup section */
572+
231B3F1E2D0879BC0069A07D /* Metrics Visualization */ = {
573+
isa = PBXGroup;
574+
children = (
575+
2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */,
576+
231B3F202D087A4C0069A07D /* MetricTableColumn.swift */,
577+
231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */,
578+
2373AE142D0A24930086C749 /* MetricsSeriesList.swift */,
579+
);
580+
path = "Metrics Visualization";
581+
sourceTree = "<group>";
582+
};
583+
231B3F232D087C020069A07D /* Metrics Columns */ = {
584+
isa = PBXGroup;
585+
children = (
586+
231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */,
587+
2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */,
588+
231B3F262D0885240069A07D /* MetricsColumnDetail.swift */,
589+
);
590+
path = "Metrics Columns";
591+
sourceTree = "<group>";
592+
};
558593
251926882C3BAF2E00249DF5 /* Actions */ = {
559594
isa = PBXGroup;
560595
children = (
@@ -935,6 +970,7 @@
935970
DDC2E18826CE24EE0042C5E4 /* Model */ = {
936971
isa = PBXGroup;
937972
children = (
973+
231B3F1E2D0879BC0069A07D /* Metrics Visualization */,
938974
DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */,
939975
);
940976
path = Model;
@@ -1036,6 +1072,7 @@
10361072
DDDB26402AABEF7B003AFCB7 /* Helpers */ = {
10371073
isa = PBXGroup;
10381074
children = (
1075+
231B3F232D087C020069A07D /* Metrics Columns */,
10391076
DDAD49EB2AFAE82500B4425D /* Map */,
10401077
DDDB26432AAC0206003AFCB7 /* NodeDetail.swift */,
10411078
DDDB26452AACC0B7003AFCB7 /* NodeInfoItem.swift */,
@@ -1311,6 +1348,7 @@
13111348
DD5D0A9C2931B9F200F7EA61 /* EthernetModes.swift in Sources */,
13121349
6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */,
13131350
DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */,
1351+
231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */,
13141352
DDC2E1A726CEB3400042C5E4 /* LocationHelper.swift in Sources */,
13151353
DD77093D2AA1AFA3007A8BF0 /* ChannelTips.swift in Sources */,
13161354
6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */,
@@ -1327,11 +1365,13 @@
13271365
DDB6ABD628AE742000384BA1 /* BluetoothConfig.swift in Sources */,
13281366
251926902C3CB44900249DF5 /* ClientHistoryButton.swift in Sources */,
13291367
DDD5BB102C285FB3007E03CA /* AppLogFilter.swift in Sources */,
1368+
2373AE172D0A26620086C749 /* EnviornmentDefaultSeries.swift in Sources */,
13301369
DD4640202AFF10F4002A5ECB /* WaypointForm.swift in Sources */,
13311370
DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */,
13321371
DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */,
13331372
DD15E4F32B8BA56E00654F61 /* PaxCounterConfig.swift in Sources */,
13341373
DDDB445229F8ACF900EE2349 /* Date.swift in Sources */,
1374+
2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */,
13351375
DDC4D568275499A500A4208E /* Persistence.swift in Sources */,
13361376
DDD6EEAF29BC024700383354 /* Firmware.swift in Sources */,
13371377
DD77093B2AA1ABB8007A8BF0 /* BluetoothTips.swift in Sources */,
@@ -1341,7 +1381,9 @@
13411381
DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */,
13421382
DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */,
13431383
DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */,
1384+
231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */,
13441385
25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */,
1386+
2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */,
13451387
DD354FD92BD96A0B0061A25F /* IAQScale.swift in Sources */,
13461388
DDDB445429F8AD1600EE2349 /* Data.swift in Sources */,
13471389
DDDB26462AACC0B7003AFCB7 /* NodeInfoItem.swift in Sources */,
@@ -1425,6 +1467,8 @@
14251467
DD3CC24C2C498D6C001BD3A2 /* BatteryCompact.swift in Sources */,
14261468
BCB613812C67290800485544 /* SendWaypointIntent.swift in Sources */,
14271469
DD1B8F402B35E2F10022AABC /* GPSStatus.swift in Sources */,
1470+
231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */,
1471+
231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */,
14281472
DD8ED9C52898D51F00B3B0AB /* NetworkConfig.swift in Sources */,
14291473
DDC3B274283F411B00AC321C /* LastHeardText.swift in Sources */,
14301474
DDDE5A1029AFE69700490C6C /* MeshActivityAttributes.swift in Sources */,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//
2+
// SeriesConfigurationEntry.swift
3+
// Meshtastic
4+
//
5+
// Created by Jake Bordens on 12/7/24.
6+
//
7+
8+
import Charts
9+
import OSLog
10+
import SwiftUI
11+
12+
// MetricsTableColumn stores metadata about an attribute in TelemetryEntity.
13+
// Given a keypath, this class holds information about how to render the attrbute in
14+
// the table. MetricsTableColumn objects are collected in a MetricsColumnList
15+
class MetricsTableColumn: ObservableObject {
16+
// CoreData Attribute Name on TelemetryEntity
17+
let attribute: String
18+
19+
// Heading for wider tables
20+
let name: String
21+
22+
// Heading for space-constrained tables
23+
let abbreviatedName: String
24+
25+
// Minimum/maximum grid width for this column
26+
let minWidth: CGFloat?
27+
let maxWidth: CGFloat?
28+
29+
// Recommended spacing, may be overridden
30+
let spacing: CGFloat
31+
// Should this column appear in the table
32+
33+
var visible: Bool
34+
35+
// Closure to render the table cell
36+
let tableBodyClosure: (MetricsTableColumn, TelemetryEntity) -> AnyView?
37+
38+
// Main initializer
39+
init<Value, TableContent: View>(
40+
keyPath: KeyPath<TelemetryEntity, Value>,
41+
name: String,
42+
abbreviatedName: String,
43+
minWidth: CGFloat? = nil,
44+
maxWidth: CGFloat? = nil,
45+
spacing: CGFloat = 0.1,
46+
visible: Bool = true,
47+
@ViewBuilder tableBody: @escaping (MetricsTableColumn, Value) -> TableContent?
48+
) {
49+
// This works because TelemetryEntity is an NSManagedObject and derrived from NSObject
50+
self.attribute = NSExpression(forKeyPath: keyPath).keyPath
51+
self.name = name
52+
self.abbreviatedName = abbreviatedName
53+
self.minWidth = minWidth
54+
self.maxWidth = maxWidth
55+
self.spacing = spacing
56+
self.visible = visible
57+
self.tableBodyClosure = { config, entity in
58+
AnyView(tableBody(config, entity[keyPath: keyPath]))
59+
}
60+
}
61+
62+
var gridItemSize: GridItem.Size {
63+
if let minWidth, let maxWidth {
64+
return .flexible(minimum: minWidth, maximum: maxWidth)
65+
}
66+
return .flexible()
67+
}
68+
69+
func body(_ te: TelemetryEntity) -> AnyView? {
70+
return tableBodyClosure(self, te)
71+
}
72+
}
73+
74+
extension MetricsTableColumn: Identifiable, Hashable {
75+
var id: String { self.attribute }
76+
77+
static func == (lhs: MetricsTableColumn, rhs: MetricsTableColumn) -> Bool {
78+
lhs.attribute == rhs.attribute
79+
}
80+
81+
func hash(into hasher: inout Hasher) {
82+
hasher.combine(attribute)
83+
}
84+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
//
2+
// MetricsChartSeries.swift
3+
// Meshtastic
4+
//
5+
// Created by Jake Bordens on 12/11/24.
6+
//
7+
8+
import Charts
9+
import Foundation
10+
import SwiftUI
11+
12+
// MetricsChartSeries stores metadata about an attribute in TelemetryEntity.
13+
// Given a keypath, this class holds information about how to render the attrbute in a
14+
// the chart. MetricsChartSeries objects are collected in a MetricsSeriesList
15+
class MetricsChartSeries: ObservableObject {
16+
17+
// CoreData Attribute Name on TelemetryEntity
18+
let attribute: String
19+
20+
// Heading for areas that have the room
21+
let name: String
22+
23+
// Heading for space-constrained areas
24+
let abbreviatedName: String
25+
26+
// Should this column appear in the chart
27+
var visible: Bool
28+
29+
// A closure that will provide the foreground style given the data set and overall chart range
30+
let foregroundStyle: (ClosedRange<Float>?) -> AnyShapeStyle?
31+
32+
// A closure that will provide the Chart Content for this series
33+
let chartBodyClosure:
34+
(MetricsChartSeries, ClosedRange<Float>?, TelemetryEntity) -> AnyChartContent? // Closure to render the chart
35+
36+
// A closure that will privide the value of a TelemetryEntity for this series
37+
// Possibly converted to the proper units
38+
let valueClosure: (TelemetryEntity) -> Float?
39+
40+
// Main initializer
41+
init<Value, ChartBody: ChartContent, ForegroundStyle: ShapeStyle>(
42+
keyPath: KeyPath<TelemetryEntity, Value>,
43+
name: String,
44+
abbreviatedName: String,
45+
conversion: ((Value) -> Value)? = nil,
46+
visible: Bool = true,
47+
foregroundStyle: @escaping ((ClosedRange<Float>?) -> ForegroundStyle?) = { _ in nil },
48+
@ChartContentBuilder chartBody: @escaping (MetricsChartSeries, ClosedRange<Float>?, Date, Value) -> ChartBody?
49+
) where Value: Plottable & Comparable {
50+
51+
// This works because TelemetryEntity is an NSManagedObject and derrived from NSObject
52+
self.attribute = NSExpression(forKeyPath: keyPath).keyPath
53+
self.name = name
54+
self.abbreviatedName = abbreviatedName
55+
self.visible = visible
56+
57+
// By saving these closures, MetricsChartSeries can be type agnostic
58+
// This is a less elegant form of type erasure, but doesn't require a new Any-type
59+
self.foregroundStyle = { range in foregroundStyle(range).map({ AnyShapeStyle($0) }) }
60+
self.chartBodyClosure = { series, range, entity in
61+
AnyChartContent(
62+
chartBody(series, range, entity.time!, entity[keyPath: keyPath]))
63+
}
64+
self.valueClosure = { te in
65+
if let conversion {
66+
return conversion(te[keyPath: keyPath]).floatValue
67+
}
68+
return te[keyPath: keyPath].floatValue
69+
}
70+
}
71+
72+
// Return the value for this series attribute given a full row of telemetry data
73+
func valueFor(_ te: TelemetryEntity) -> Float? {
74+
return self.valueClosure(te)?.floatValue
75+
}
76+
77+
// Return the chart content for this series given a full row of telemetry data
78+
func body<T>(_ te: TelemetryEntity, inChartRange chartRange: ClosedRange<T>? = nil) -> AnyChartContent? where T: BinaryFloatingPoint {
79+
let range = chartRange.map { Float($0.lowerBound)...Float($0.upperBound) }
80+
return chartBodyClosure(self, range, te)
81+
}
82+
}
83+
84+
extension MetricsChartSeries: Identifiable, Hashable {
85+
var id: String { self.attribute }
86+
87+
static func == (lhs: MetricsChartSeries, rhs: MetricsChartSeries) -> Bool {
88+
lhs.attribute == rhs.attribute
89+
}
90+
91+
func hash(into hasher: inout Hasher) {
92+
hasher.combine(attribute)
93+
}
94+
}
95+
96+
extension Plottable {
97+
var floatValue: Float? {
98+
if let integerValue = self.primitivePlottable as? any BinaryInteger {
99+
return Float(integerValue)
100+
} else if let floatingPointValue = self.primitivePlottable as? any BinaryFloatingPoint {
101+
return Float(floatingPointValue)
102+
}
103+
return nil
104+
}
105+
var doubleValue: Double? {
106+
if let integerValue = self.primitivePlottable as? any BinaryInteger {
107+
return Double(integerValue)
108+
} else if let floatingPointValue = self.primitivePlottable as? any BinaryFloatingPoint {
109+
return Double(floatingPointValue)
110+
}
111+
return nil
112+
}
113+
}

0 commit comments

Comments
 (0)