mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge pull request #1016 from jake-b/configurable_columns
Configurable table columns and chart series for the Environment Metrics Log
This commit is contained in:
commit
d02732d901
11 changed files with 965 additions and 87 deletions
|
|
@ -4517,6 +4517,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Chart" : {
|
||||
|
||||
},
|
||||
"CHG" : {
|
||||
"localizations" : {
|
||||
|
|
@ -4869,6 +4872,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Config" : {
|
||||
|
||||
},
|
||||
"config.module.paxcounter.enabled.description" : {
|
||||
"localizations" : {
|
||||
|
|
@ -9245,6 +9251,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Done" : {
|
||||
|
||||
},
|
||||
"Double Tap as Button" : {
|
||||
"localizations" : {
|
||||
|
|
@ -19348,6 +19357,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Metric" : {
|
||||
|
||||
},
|
||||
"Minimum Distance" : {
|
||||
"localizations" : {
|
||||
|
|
@ -26503,6 +26515,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Series" : {
|
||||
|
||||
},
|
||||
"Server" : {
|
||||
"localizations" : {
|
||||
|
|
@ -27718,6 +27733,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Table" : {
|
||||
|
||||
},
|
||||
"tapback" : {
|
||||
"localizations" : {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@
|
|||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
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 */; };
|
||||
|
|
@ -259,6 +266,13 @@
|
|||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
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>"; };
|
||||
|
|
@ -555,6 +569,27 @@
|
|||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
231B3F1E2D0879BC0069A07D /* Metrics Visualization */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */,
|
||||
231B3F202D087A4C0069A07D /* MetricTableColumn.swift */,
|
||||
231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */,
|
||||
2373AE142D0A24930086C749 /* MetricsSeriesList.swift */,
|
||||
);
|
||||
path = "Metrics Visualization";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
231B3F232D087C020069A07D /* Metrics Columns */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */,
|
||||
2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */,
|
||||
231B3F262D0885240069A07D /* MetricsColumnDetail.swift */,
|
||||
);
|
||||
path = "Metrics Columns";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
251926882C3BAF2E00249DF5 /* Actions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -935,6 +970,7 @@
|
|||
DDC2E18826CE24EE0042C5E4 /* Model */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
231B3F1E2D0879BC0069A07D /* Metrics Visualization */,
|
||||
DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */,
|
||||
);
|
||||
path = Model;
|
||||
|
|
@ -1036,6 +1072,7 @@
|
|||
DDDB26402AABEF7B003AFCB7 /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
231B3F232D087C020069A07D /* Metrics Columns */,
|
||||
DDAD49EB2AFAE82500B4425D /* Map */,
|
||||
DDDB26432AAC0206003AFCB7 /* NodeDetail.swift */,
|
||||
DDDB26452AACC0B7003AFCB7 /* NodeInfoItem.swift */,
|
||||
|
|
@ -1311,6 +1348,7 @@
|
|||
DD5D0A9C2931B9F200F7EA61 /* EthernetModes.swift in Sources */,
|
||||
6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */,
|
||||
DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */,
|
||||
231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */,
|
||||
DDC2E1A726CEB3400042C5E4 /* LocationHelper.swift in Sources */,
|
||||
DD77093D2AA1AFA3007A8BF0 /* ChannelTips.swift in Sources */,
|
||||
6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */,
|
||||
|
|
@ -1327,11 +1365,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 */,
|
||||
|
|
@ -1341,7 +1381,9 @@
|
|||
DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */,
|
||||
DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */,
|
||||
DDC94FC129CE063B0082EA6E /* BatteryLevel.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 */,
|
||||
|
|
@ -1425,6 +1467,8 @@
|
|||
DD3CC24C2C498D6C001BD3A2 /* BatteryCompact.swift in Sources */,
|
||||
BCB613812C67290800485544 /* SendWaypointIntent.swift in Sources */,
|
||||
DD1B8F402B35E2F10022AABC /* GPSStatus.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 */,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
//
|
||||
// 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 {
|
||||
// CoreData Attribute Name on TelemetryEntity
|
||||
let attribute: String
|
||||
|
||||
// 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>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
113
Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift
Normal file
113
Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
//
|
||||
// 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 {
|
||||
|
||||
// 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, 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, ForegroundStyle: ShapeStyle>(
|
||||
keyPath: KeyPath<TelemetryEntity, Value>,
|
||||
name: String,
|
||||
abbreviatedName: String,
|
||||
conversion: ((Value) -> Value)? = nil,
|
||||
visible: Bool = true,
|
||||
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
|
||||
|
||||
// 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, range, entity.time!, entity[keyPath: keyPath]))
|
||||
}
|
||||
self.valueClosure = { te in
|
||||
if let conversion {
|
||||
return conversion(te[keyPath: keyPath]).floatValue
|
||||
}
|
||||
return te[keyPath: keyPath].floatValue
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
func column(forAttribute attribute: String) -> MetricsTableColumn? {
|
||||
return columns.first(where: { $0.attribute == attribute})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
109
Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift
Normal file
109
Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
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 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -17,6 +17,11 @@ struct EnvironmentMetricsLog: View {
|
|||
@State var exportString = ""
|
||||
@ObservedObject var node: NodeInfoEntity
|
||||
|
||||
@StateObject var columnList = MetricsColumnList.environmentDefaultColumns
|
||||
@StateObject var seriesList = MetricsSeriesList.environmentDefaultChartSeries
|
||||
|
||||
@State var isEditingColumnConfiguration = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if node.hasEnvironmentMetrics {
|
||||
|
|
@ -25,129 +30,70 @@ 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
|
||||
let chartRange = applyMargins(seriesList.chartRange(forData: chartData))
|
||||
VStack {
|
||||
if chartData.count > 0 {
|
||||
GroupBox(label: Label("\(environmentMetrics.count) Readings Total", systemImage: "chart.xyaxis.line")) {
|
||||
Chart {
|
||||
Chart(seriesList.visible) { series in
|
||||
ForEach(chartData, id: \.time) { dataPoint in
|
||||
AreaMark(
|
||||
x: .value("Time", dataPoint.time!),
|
||||
y: .value("Temperature", dataPoint.temperature.localeTemperature()),
|
||||
stacking: .unstacked
|
||||
)
|
||||
.interpolationMethod(.cardinal)
|
||||
.foregroundStyle(
|
||||
.linearGradient(
|
||||
colors: [.blue, .yellow, .orange, .red, .red],
|
||||
startPoint: .bottom, endPoint: .top
|
||||
)
|
||||
.opacity(0.6)
|
||||
)
|
||||
.alignsMarkStylesWithPlotArea()
|
||||
.accessibilityHidden(true)
|
||||
LineMark(
|
||||
x: .value("Time", dataPoint.time!),
|
||||
y: .value("Temperature", dataPoint.temperature.localeTemperature())
|
||||
)
|
||||
.interpolationMethod(.cardinal)
|
||||
.foregroundStyle(
|
||||
.linearGradient(
|
||||
colors: [.blue, .yellow, .orange, .red, .red],
|
||||
startPoint: .bottom, endPoint: .top
|
||||
)
|
||||
)
|
||||
.lineStyle(StrokeStyle(lineWidth: 4))
|
||||
.alignsMarkStylesWithPlotArea()
|
||||
series.body(dataPoint, inChartRange: chartRange)
|
||||
}
|
||||
}
|
||||
.chartXAxis(content: {
|
||||
AxisMarks(position: .top)
|
||||
})
|
||||
.chartYScale(domain: format == .celsius ? -20...55 : 0...125)
|
||||
.chartForegroundStyleScale([
|
||||
"Temperature": .clear
|
||||
])
|
||||
.chartYScale(domain: chartRange)
|
||||
.chartForegroundStyleScale { (seriesName: String) -> AnyShapeStyle in
|
||||
return seriesList.foregroundStyle(forAbbreviatedName: seriesName, chartRange: chartRange) ?? AnyShapeStyle(Color.clear)
|
||||
}
|
||||
.chartLegend(position: .automatic, alignment: .bottom)
|
||||
}
|
||||
}
|
||||
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current)
|
||||
let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "")
|
||||
|
||||
// Dynamic table column using SwiftUI Table requires TableColumnForEach which requires the target
|
||||
// to be bumped to 17.4 -- Until that happens, the existing non-configurable table is used.
|
||||
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
|
||||
// Add a table for mac and ipad
|
||||
Table(environmentMetrics) {
|
||||
TableColumn("Temperature") { em in
|
||||
Text(em.temperature.formattedTemperature())
|
||||
columnList.column(forAttribute: "temperature")?.body(em)
|
||||
}
|
||||
TableColumn("Humidity") { em in
|
||||
Text("\(String(format: "%.0f", em.relativeHumidity))%")
|
||||
columnList.column(forAttribute: "relativeHumidity")?.body(em)
|
||||
}
|
||||
TableColumn("Barometric Pressure") { em in
|
||||
Text("\(String(format: "%.1f", em.barometricPressure)) hPa")
|
||||
columnList.column(forAttribute: "barometricPressure")?.body(em)
|
||||
}
|
||||
TableColumn("Indoor Air Quality") { em in
|
||||
HStack {
|
||||
Text("IAQ")
|
||||
IndoorAirQuality(iaq: Int(em.iaq), displayMode: IaqDisplayMode.dot )
|
||||
}
|
||||
columnList.column(forAttribute: "iaq")?.body(em)
|
||||
}
|
||||
TableColumn("Wind Speed") { em in
|
||||
let windSpeed = Measurement(value: Double(em.windSpeed), unit: UnitSpeed.kilometersPerHour)
|
||||
Text(windSpeed.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))))
|
||||
columnList.column(forAttribute: "windSpeed")?.body(em)
|
||||
}
|
||||
TableColumn("Wind Direction") { em in
|
||||
let direction = cardinalValue(from: Double(em.windDirection))
|
||||
Text(direction)
|
||||
columnList.column(forAttribute: "windDirection")?.body(em)
|
||||
}
|
||||
TableColumn("timestamp") { em in
|
||||
Text(em.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized)
|
||||
columnList.column(forAttribute: "time")?.body(em)
|
||||
}
|
||||
.width(min: 180)
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
let columns = [
|
||||
GridItem(.flexible(minimum: 30, maximum: 50), spacing: 0.1),
|
||||
GridItem(.flexible(minimum: 30, maximum: 60), spacing: 0.1),
|
||||
GridItem(.flexible(minimum: 30, maximum: 60), spacing: 0.1),
|
||||
GridItem(.flexible(minimum: 30, maximum: 70), spacing: 0.1),
|
||||
GridItem(spacing: 0)
|
||||
]
|
||||
LazyVGrid(columns: columns, alignment: .leading, spacing: 1, pinnedViews: [.sectionHeaders]) {
|
||||
|
||||
LazyVGrid(columns: columnList.gridItems, alignment: .leading, spacing: 1, pinnedViews: [.sectionHeaders]) {
|
||||
GridRow {
|
||||
Text("Temp")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
Text("Hum")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
Text("Bar")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
Text("IAQ")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
Text("timestamp")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
ForEach(columnList.visible) { col in
|
||||
Text(col.abbreviatedName)
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
}
|
||||
ForEach(environmentMetrics, id: \.self) { em in
|
||||
|
||||
GridRow {
|
||||
|
||||
Text(em.temperature.formattedTemperature())
|
||||
.font(.caption)
|
||||
Text("\(String(format: "%.0f", em.relativeHumidity))%")
|
||||
.font(.caption)
|
||||
Text("\(String(format: "%.1f", em.barometricPressure))")
|
||||
.font(.caption)
|
||||
IndoorAirQuality(iaq: Int(em.iaq), displayMode: .dot)
|
||||
.font(.caption)
|
||||
Text(em.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized)
|
||||
.font(.caption)
|
||||
ForEach(columnList.visible) { col in
|
||||
col.body(em)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -157,7 +103,18 @@ struct EnvironmentMetricsLog: View {
|
|||
}
|
||||
}
|
||||
HStack {
|
||||
|
||||
Button {
|
||||
self.isEditingColumnConfiguration = true
|
||||
} label: {
|
||||
Label("Config", systemImage: "gearshape")
|
||||
} .buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding(.bottom)
|
||||
.padding(.leading)
|
||||
.sheet(isPresented: self.$isEditingColumnConfiguration) {
|
||||
MetricsColumnDetail(columnList: columnList, seriesList: seriesList)
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
isPresentingClearLogConfirm = true
|
||||
} label: {
|
||||
|
|
@ -219,4 +176,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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
//
|
||||
// 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",
|
||||
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),
|
||||
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.abbreviatedName, temperature.localeTemperature())
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
.foregroundStyle(by: .value("Series", series.abbreviatedName))
|
||||
.lineStyle(StrokeStyle(lineWidth: 4))
|
||||
.alignsMarkStylesWithPlotArea()
|
||||
}),
|
||||
|
||||
// Relative Humidity Series Configuration
|
||||
MetricsChartSeries(
|
||||
keyPath: \.relativeHumidity,
|
||||
name: "Relative Humidity",
|
||||
abbreviatedName: "Hum",
|
||||
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.abbreviatedName, humidity)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
.foregroundStyle(by: .value("Series", series.abbreviatedName))
|
||||
.lineStyle(StrokeStyle(lineWidth: 4))
|
||||
.alignsMarkStylesWithPlotArea()
|
||||
}),
|
||||
|
||||
// Barometric Pressure Series Configuration
|
||||
MetricsChartSeries(
|
||||
keyPath: \.barometricPressure,
|
||||
name: "Barometric Pressure",
|
||||
abbreviatedName: "Bar",
|
||||
visible: false,
|
||||
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.abbreviatedName, pressure)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
.foregroundStyle(by: .value("Series", series.abbreviatedName))
|
||||
.lineStyle(StrokeStyle(lineWidth: 4))
|
||||
.alignsMarkStylesWithPlotArea()
|
||||
|
||||
}),
|
||||
|
||||
// Indoor Air Quality Series Configuration
|
||||
MetricsChartSeries(
|
||||
keyPath: \.iaq,
|
||||
name: "Indoor Air Quality",
|
||||
abbreviatedName: "IAQ",
|
||||
visible: false,
|
||||
foregroundStyle: { _ in .gray },
|
||||
chartBody: { series, _, time, iaq in
|
||||
let iaqEnum = Iaq.getIaq(for: Int(iaq))
|
||||
PointMark(
|
||||
x: .value("Time", time),
|
||||
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
|
||||
MetricsChartSeries(
|
||||
keyPath: \.windSpeedAndDirection,
|
||||
name: "Wind Speed/Direction",
|
||||
abbreviatedName: "Speed/Dir",
|
||||
visible: false,
|
||||
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.abbreviatedName, wsad.windSpeed)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
.foregroundStyle(by: .value("Series", series.abbreviatedName))
|
||||
.lineStyle(StrokeStyle(lineWidth: 4))
|
||||
.alignsMarkStylesWithPlotArea()
|
||||
PointMark(
|
||||
x: .value("Time", time),
|
||||
y: .value(series.abbreviatedName, wsad.windSpeed)
|
||||
)
|
||||
.symbol {
|
||||
Image(systemName: "location.north.circle.fill")
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(Color.white, Color(UIColor.yellow.darker(componentDelta: 0.3)))
|
||||
.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, 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
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
//
|
||||
// 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())
|
||||
}),
|
||||
|
||||
// Relative Humidity Series Configuration
|
||||
MetricsTableColumn(
|
||||
keyPath: \.relativeHumidity,
|
||||
name: "Relative Humidity",
|
||||
abbreviatedName: "Hum",
|
||||
minWidth: 25, maxWidth: 40,
|
||||
tableBody: { _, humidity in
|
||||
Text("\(String(format: "%.0f", humidity))%")
|
||||
}),
|
||||
|
||||
// Barometric Pressure Series Configuration
|
||||
MetricsTableColumn(
|
||||
keyPath: \.barometricPressure,
|
||||
name: "Barometric Pressure",
|
||||
abbreviatedName: "Bar",
|
||||
minWidth: 30, maxWidth: 50,
|
||||
tableBody: { _, pressure in
|
||||
if (UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) {
|
||||
Text("\(String(format: "%.1f hPa", pressure))")
|
||||
} else {
|
||||
Text("\(String(format: "%.1f", pressure))")
|
||||
}
|
||||
}),
|
||||
|
||||
// Indoor Air Quality Series Configuration
|
||||
MetricsTableColumn(
|
||||
keyPath: \.iaq,
|
||||
name: "Indoor Air Quality",
|
||||
abbreviatedName: "IAQ",
|
||||
minWidth: 25, maxWidth: 50,
|
||||
tableBody: { _, iaq in
|
||||
IndoorAirQuality(iaq: Int(iaq), displayMode: .dot)
|
||||
}),
|
||||
|
||||
// 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)
|
||||
.scaleEffect(0.9, anchor: .center)
|
||||
.rotationEffect(.degrees(wind))
|
||||
.foregroundStyle(.blue)
|
||||
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
|
||||
Text(cardinalValue(from: wind))
|
||||
} else {
|
||||
Text(abbreviatedCardinalValue(from: wind))
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// 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))))
|
||||
)
|
||||
}),
|
||||
|
||||
// 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
|
||||
)
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
//
|
||||
// MetricsColumnDetail.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 12/10/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MetricsColumnDetail: View {
|
||||
@ObservedObject var columnList: MetricsColumnList
|
||||
@ObservedObject var seriesList: MetricsSeriesList
|
||||
|
||||
@State private var currentDetent = PresentationDetent.medium
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
List {
|
||||
Section("Chart") {
|
||||
ForEach(seriesList) { series in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(series.foregroundStyle(0.0...100.0) ?? AnyShapeStyle(.clear))
|
||||
.frame(width: 20.0, height: 20.0)
|
||||
Text(series.name)
|
||||
Spacer()
|
||||
if series.visible {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}.contentShape(Rectangle()) // Ensures the entire row is tappable
|
||||
.onTapGesture {
|
||||
seriesList.toggleVisibity(for: series)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dynamic table column using SwiftUI Table requires TableColumnForEach which requires the target
|
||||
// to be bumped to 17.4 -- Until that happens, the existing non-configurable table is used.
|
||||
if !(UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) {
|
||||
Section("Table") {
|
||||
ForEach(columnList.columns) { column in
|
||||
HStack {
|
||||
Text(column.name)
|
||||
Spacer()
|
||||
if column.visible {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}.contentShape(Rectangle()) // Ensures the entire row is tappable
|
||||
.onTapGesture {
|
||||
columnList.objectWillChange.send()
|
||||
columnList.toggleVisibity(for: column)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// More friendly to tap a button to dismiss on these devices
|
||||
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
|
||||
Spacer()
|
||||
Button {
|
||||
self.dismiss()
|
||||
} label: {
|
||||
Text("Done")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding([.leading, .trailing, .bottom])
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large], selection: $currentDetent)
|
||||
.presentationContentInteraction(.scrolls)
|
||||
.presentationDragIndicator(
|
||||
!(UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) ? .visible : .hidden)
|
||||
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
|
||||
.interactiveDismissDisabled(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -465,3 +465,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 ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue