mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Initial implementation of configurable Environment Metrics visualization
This commit is contained in:
parent
5c443509b3
commit
7ef92bb29b
7 changed files with 452 additions and 68 deletions
|
|
@ -3241,6 +3241,9 @@
|
|||
},
|
||||
"Channels being added from the QR code did not save. When adding channels the names must be unique." : {
|
||||
|
||||
},
|
||||
"Chart" : {
|
||||
|
||||
},
|
||||
"CHG" : {
|
||||
|
||||
|
|
@ -3507,6 +3510,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Config" : {
|
||||
|
||||
},
|
||||
"config.module.paxcounter.enabled.description" : {
|
||||
"localizations" : {
|
||||
|
|
@ -15132,6 +15138,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Metric" : {
|
||||
|
||||
},
|
||||
"Minimum Distance" : {
|
||||
"localizations" : {
|
||||
|
|
@ -21535,6 +21544,9 @@
|
|||
},
|
||||
"Supported I2C Connected sensors will be detected automatically, sensors are BMP280, BME280, BME680, MCP9808, INA219, INA260, LPS22 and SHTC3." : {
|
||||
|
||||
},
|
||||
"Table" : {
|
||||
|
||||
},
|
||||
"tapback" : {
|
||||
"localizations" : {
|
||||
|
|
@ -24065,4 +24077,4 @@
|
|||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,10 @@
|
|||
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 */; };
|
||||
231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F262D0885240069A07D /* MetricsColumnDetail.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 */; };
|
||||
|
|
@ -258,6 +262,10 @@
|
|||
/* 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>"; };
|
||||
231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.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>"; };
|
||||
|
|
@ -552,6 +560,24 @@
|
|||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
231B3F1E2D0879BC0069A07D /* Metrics Columns */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
231B3F1F2D087A4C0069A07D /* MetricsColumnConfiguration.swift */,
|
||||
231B3F202D087A4C0069A07D /* MetricColumnConfigurationEntry.swift */,
|
||||
);
|
||||
path = "Metrics Columns";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
231B3F232D087C020069A07D /* Metrics Columns */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
231B3F242D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift */,
|
||||
231B3F262D0885240069A07D /* MetricsColumnDetail.swift */,
|
||||
);
|
||||
path = "Metrics Columns";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
251926882C3BAF2E00249DF5 /* Actions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -931,6 +957,7 @@
|
|||
DDC2E18826CE24EE0042C5E4 /* Model */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
231B3F1E2D0879BC0069A07D /* Metrics Columns */,
|
||||
DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */,
|
||||
);
|
||||
path = Model;
|
||||
|
|
@ -1032,6 +1059,7 @@
|
|||
DDDB26402AABEF7B003AFCB7 /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
231B3F232D087C020069A07D /* Metrics Columns */,
|
||||
DDAD49EB2AFAE82500B4425D /* Map */,
|
||||
DDDB26432AAC0206003AFCB7 /* NodeDetail.swift */,
|
||||
DDDB26452AACC0B7003AFCB7 /* NodeInfoItem.swift */,
|
||||
|
|
@ -1306,6 +1334,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 */,
|
||||
|
|
@ -1335,6 +1364,7 @@
|
|||
DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */,
|
||||
DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */,
|
||||
DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */,
|
||||
231B3F252D087C3C0069A07D /* EnvironmentMetricsColumnDefaults.swift in Sources */,
|
||||
25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */,
|
||||
DD354FD92BD96A0B0061A25F /* IAQScale.swift in Sources */,
|
||||
DDDB445429F8AD1600EE2349 /* Data.swift in Sources */,
|
||||
|
|
@ -1419,6 +1449,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 */,
|
||||
DD8ED9C52898D51F00B3B0AB /* NetworkConfig.swift in Sources */,
|
||||
DDC3B274283F411B00AC321C /* LastHeardText.swift in Sources */,
|
||||
DDDE5A1029AFE69700490C6C /* MeshActivityAttributes.swift in Sources */,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,9 @@ struct EnvironmentMetricsLog: View {
|
|||
@State var exportString = ""
|
||||
@ObservedObject var node: NodeInfoEntity
|
||||
|
||||
@StateObject var columnConfiguration = MetricsColumnConfiguration.environmentDefaults
|
||||
@State var isEditingColumnConfiguration = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if node.hasEnvironmentMetrics {
|
||||
|
|
@ -31,42 +34,15 @@ struct EnvironmentMetricsLog: View {
|
|||
VStack {
|
||||
if chartData.count > 0 {
|
||||
GroupBox(label: Label("\(environmentMetrics.count) Readings Total", systemImage: "chart.xyaxis.line")) {
|
||||
Chart {
|
||||
Chart(columnConfiguration.activeChartColumns, id: \.columnName) { 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.chartBody(dataPoint)
|
||||
}
|
||||
}
|
||||
.chartXAxis(content: {
|
||||
AxisMarks(position: .top)
|
||||
})
|
||||
.chartYScale(domain: format == .celsius ? -20...55 : 0...125)
|
||||
// .chartYScale(domain: format == .celsius ? -20...55 : 0...125)
|
||||
.chartForegroundStyleScale([
|
||||
"Temperature": .clear
|
||||
])
|
||||
|
|
@ -108,46 +84,19 @@ struct EnvironmentMetricsLog: View {
|
|||
}
|
||||
} 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: columnConfiguration.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(columnConfiguration.activeTableColumns) { col in
|
||||
Text(col.abbreviatedColumnName)
|
||||
.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(columnConfiguration.activeTableColumns) { col in
|
||||
col.tableBody(em)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -157,7 +106,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(metricsColumnConfiguration: self.columnConfiguration)
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
isPresentingClearLogConfirm = true
|
||||
} label: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
//
|
||||
// MetricsColumnDetail.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 12/10/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MetricsColumnDetail: View {
|
||||
@ObservedObject var metricsColumnConfiguration: MetricsColumnConfiguration
|
||||
@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
|
||||
HStack {
|
||||
Text(column.columnName)
|
||||
Spacer()
|
||||
if column.showInChart {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}.contentShape(Rectangle()) // Ensures the entire row is tappable
|
||||
.onTapGesture {
|
||||
metricsColumnConfiguration.objectWillChange.send()
|
||||
column.showInChart.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Table") {
|
||||
ForEach(metricsColumnConfiguration.columns.filter({$0.availability.contains(.table)}), id:\.self) { column in
|
||||
HStack {
|
||||
Text(column.columnName)
|
||||
Spacer()
|
||||
if column.showInTable {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}.contentShape(Rectangle()) // Ensures the entire row is tappable
|
||||
.onTapGesture {
|
||||
metricsColumnConfiguration.objectWillChange.send()
|
||||
column.showInTable.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large], selection: $currentDetent)
|
||||
.presentationContentInteraction(.scrolls)
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue