@@ -16,8 +16,8 @@ |
||
| 16 | 16 |
432EA6442445A559006FC905 /* ChartContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432EA6432445A559006FC905 /* ChartContext.swift */; };
|
| 17 | 17 |
4347F01D28D717C1007EE7B1 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 4347F01C28D717C1007EE7B1 /* CryptoSwift */; };
|
| 18 | 18 |
4351E7BB24685ACD00E798A3 /* CGPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4351E7BA24685ACD00E798A3 /* CGPoint.swift */; };
|
| 19 |
- 43554B2F24443939004E66F5 /* AppHistorySheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B2E24443939004E66F5 /* AppHistorySheetView.swift */; };
|
|
| 20 |
- 43554B32244449B5004E66F5 /* AppHistorySampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B31244449B5004E66F5 /* AppHistorySampleView.swift */; };
|
|
| 19 |
+ 43554B2F24443939004E66F5 /* MeasurementSeriesSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B2E24443939004E66F5 /* MeasurementSeriesSheetView.swift */; };
|
|
| 20 |
+ 43554B32244449B5004E66F5 /* MeasurementSeriesSampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B31244449B5004E66F5 /* MeasurementSeriesSampleView.swift */; };
|
|
| 21 | 21 |
43554B3424444B0E004E66F5 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B3324444B0E004E66F5 /* Date.swift */; };
|
| 22 | 22 |
4360A34D241CBB3800B464F9 /* RSSIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4360A34C241CBB3800B464F9 /* RSSIView.swift */; };
|
| 23 | 23 |
437D47D12415F91B00B7768E /* MeterLiveContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D02415F91B00B7768E /* MeterLiveContentView.swift */; };
|
@@ -110,8 +110,8 @@ |
||
| 110 | 110 |
4327461A24619CED0009BE4B /* MeterRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterRowView.swift; sourceTree = "<group>"; };
|
| 111 | 111 |
432EA6432445A559006FC905 /* ChartContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartContext.swift; sourceTree = "<group>"; };
|
| 112 | 112 |
4351E7BA24685ACD00E798A3 /* CGPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGPoint.swift; sourceTree = "<group>"; };
|
| 113 |
- 43554B2E24443939004E66F5 /* AppHistorySheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHistorySheetView.swift; sourceTree = "<group>"; };
|
|
| 114 |
- 43554B31244449B5004E66F5 /* AppHistorySampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHistorySampleView.swift; sourceTree = "<group>"; };
|
|
| 113 |
+ 43554B2E24443939004E66F5 /* MeasurementSeriesSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementSeriesSheetView.swift; sourceTree = "<group>"; };
|
|
| 114 |
+ 43554B31244449B5004E66F5 /* MeasurementSeriesSampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementSeriesSampleView.swift; sourceTree = "<group>"; };
|
|
| 115 | 115 |
43554B3324444B0E004E66F5 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = "<group>"; };
|
| 116 | 116 |
4360A34C241CBB3800B464F9 /* RSSIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSIView.swift; sourceTree = "<group>"; };
|
| 117 | 117 |
437D47D02415F91B00B7768E /* MeterLiveContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterLiveContentView.swift; sourceTree = "<group>"; };
|
@@ -291,7 +291,7 @@ |
||
| 291 | 291 |
432F6ED8246684060043912E /* Subviews */ = {
|
| 292 | 292 |
isa = PBXGroup; |
| 293 | 293 |
children = ( |
| 294 |
- 43554B31244449B5004E66F5 /* AppHistorySampleView.swift */, |
|
| 294 |
+ 43554B31244449B5004E66F5 /* MeasurementSeriesSampleView.swift */, |
|
| 295 | 295 |
); |
| 296 | 296 |
path = Subviews; |
| 297 | 297 |
sourceTree = "<group>"; |
@@ -303,13 +303,13 @@ |
||
| 303 | 303 |
name = Frameworks; |
| 304 | 304 |
sourceTree = "<group>"; |
| 305 | 305 |
}; |
| 306 |
- 43554B3024444983004E66F5 /* AppHistory */ = {
|
|
| 306 |
+ 43554B3024444983004E66F5 /* MeasurementSeries */ = {
|
|
| 307 | 307 |
isa = PBXGroup; |
| 308 | 308 |
children = ( |
| 309 |
- 43554B2E24443939004E66F5 /* AppHistorySheetView.swift */, |
|
| 309 |
+ 43554B2E24443939004E66F5 /* MeasurementSeriesSheetView.swift */, |
|
| 310 | 310 |
432F6ED8246684060043912E /* Subviews */, |
| 311 | 311 |
); |
| 312 |
- path = AppHistory; |
|
| 312 |
+ path = MeasurementSeries; |
|
| 313 | 313 |
sourceTree = "<group>"; |
| 314 | 314 |
}; |
| 315 | 315 |
D28F11253C8E4A7A00A10035 /* Subviews */ = {
|
@@ -369,7 +369,7 @@ |
||
| 369 | 369 |
isa = PBXGroup; |
| 370 | 370 |
children = ( |
| 371 | 371 |
4308CF89241777130002E80B /* DataGroups */, |
| 372 |
- 43554B3024444983004E66F5 /* AppHistory */, |
|
| 372 |
+ 43554B3024444983004E66F5 /* MeasurementSeries */, |
|
| 373 | 373 |
D28F11273C8E4A7A00A10037 /* ChargeRecord */, |
| 374 | 374 |
); |
| 375 | 375 |
path = Sheets; |
@@ -729,13 +729,13 @@ |
||
| 729 | 729 |
432EA6442445A559006FC905 /* ChartContext.swift in Sources */, |
| 730 | 730 |
4308CF8624176CAB0002E80B /* DataGroupRowView.swift in Sources */, |
| 731 | 731 |
4386958F2F6A4E3E008855A9 /* MeterCapabilities.swift in Sources */, |
| 732 |
- 43554B32244449B5004E66F5 /* AppHistorySampleView.swift in Sources */, |
|
| 732 |
+ 43554B32244449B5004E66F5 /* MeasurementSeriesSampleView.swift in Sources */, |
|
| 733 | 733 |
43F7792B2465AE1600745DF4 /* UIView.swift in Sources */, |
| 734 | 734 |
43ED78AE2420A0BE00974487 /* BluetoothSerial.swift in Sources */, |
| 735 | 735 |
43CBF662240BF3EB00255B8B /* SceneDelegate.swift in Sources */, |
| 736 | 736 |
4351E7BB24685ACD00E798A3 /* CGPoint.swift in Sources */, |
| 737 | 737 |
4327461B24619CED0009BE4B /* MeterRowView.swift in Sources */, |
| 738 |
- 43554B2F24443939004E66F5 /* AppHistorySheetView.swift in Sources */, |
|
| 738 |
+ 43554B2F24443939004E66F5 /* MeasurementSeriesSheetView.swift in Sources */, |
|
| 739 | 739 |
430CB4FC245E07EB006525C2 /* ChevronView.swift in Sources */, |
| 740 | 740 |
43554B3424444B0E004E66F5 /* Date.swift in Sources */, |
| 741 | 741 |
4311E63A241384960080EA59 /* DeviceHelpView.swift in Sources */, |
@@ -88,6 +88,16 @@ class ChartContext {
|
||
| 88 | 88 |
self.rect = rect |
| 89 | 89 |
padding() |
| 90 | 90 |
} |
| 91 |
+ |
|
| 92 |
+ func setBounds(xMin: CGFloat, xMax: CGFloat, yMin: CGFloat, yMax: CGFloat) {
|
|
| 93 |
+ rect = CGRect( |
|
| 94 |
+ x: min(xMin, xMax), |
|
| 95 |
+ y: min(yMin, yMax), |
|
| 96 |
+ width: abs(xMax - xMin), |
|
| 97 |
+ height: max(abs(yMax - yMin), 0.1) |
|
| 98 |
+ ) |
|
| 99 |
+ padding() |
|
| 100 |
+ } |
|
| 91 | 101 |
|
| 92 | 102 |
func yAxisLabel( for itemNo: Int, of items: Int ) -> Double {
|
| 93 | 103 |
let labelSpace = Double(rect!.height) / Double(items - 1) |
@@ -13,9 +13,24 @@ class Measurements : ObservableObject {
|
||
| 13 | 13 |
|
| 14 | 14 |
class Measurement : ObservableObject {
|
| 15 | 15 |
struct Point : Identifiable , Hashable {
|
| 16 |
+ enum Kind: Hashable {
|
|
| 17 |
+ case sample |
|
| 18 |
+ case discontinuity |
|
| 19 |
+ } |
|
| 20 |
+ |
|
| 16 | 21 |
var id : Int |
| 17 | 22 |
var timestamp: Date |
| 18 | 23 |
var value: Double |
| 24 |
+ var kind: Kind = .sample |
|
| 25 |
+ |
|
| 26 |
+ var isSample: Bool {
|
|
| 27 |
+ kind == .sample |
|
| 28 |
+ } |
|
| 29 |
+ |
|
| 30 |
+ var isDiscontinuity: Bool {
|
|
| 31 |
+ kind == .discontinuity |
|
| 32 |
+ } |
|
| 33 |
+ |
|
| 19 | 34 |
func point() -> CGPoint {
|
| 20 | 35 |
return CGPoint(x: timestamp.timeIntervalSince1970, y: value) |
| 21 | 36 |
} |
@@ -24,23 +39,46 @@ class Measurements : ObservableObject {
|
||
| 24 | 39 |
var points: [Point] = [] |
| 25 | 40 |
var context = ChartContext() |
| 26 | 41 |
|
| 42 |
+ var samplePoints: [Point] {
|
|
| 43 |
+ points.filter { $0.isSample }
|
|
| 44 |
+ } |
|
| 45 |
+ |
|
| 46 |
+ private func rebuildContext() {
|
|
| 47 |
+ context.reset() |
|
| 48 |
+ for point in points where point.isSample {
|
|
| 49 |
+ context.include(point: point.point()) |
|
| 50 |
+ } |
|
| 51 |
+ } |
|
| 52 |
+ |
|
| 53 |
+ private func appendPoint(timestamp: Date, value: Double, kind: Point.Kind) {
|
|
| 54 |
+ let newPoint = Measurement.Point(id: points.count, timestamp: timestamp, value: value, kind: kind) |
|
| 55 |
+ points.append(newPoint) |
|
| 56 |
+ if newPoint.isSample {
|
|
| 57 |
+ context.include(point: newPoint.point()) |
|
| 58 |
+ } |
|
| 59 |
+ self.objectWillChange.send() |
|
| 60 |
+ } |
|
| 61 |
+ |
|
| 27 | 62 |
func removeValue(index: Int) {
|
| 28 | 63 |
points.remove(at: index) |
| 29 |
- context.reset() |
|
| 30 |
- for point in points {
|
|
| 31 |
- context.include( point: point.point() ) |
|
| 64 |
+ for index in points.indices {
|
|
| 65 |
+ points[index].id = index |
|
| 32 | 66 |
} |
| 67 |
+ rebuildContext() |
|
| 33 | 68 |
self.objectWillChange.send() |
| 34 | 69 |
} |
| 35 | 70 |
|
| 36 | 71 |
func addPoint(timestamp: Date, value: Double) {
|
| 37 |
- let newPoint = Measurement.Point(id: points.count, timestamp: timestamp, value: value) |
|
| 38 |
- points.append(newPoint) |
|
| 39 |
- context.include( point: newPoint.point() ) |
|
| 40 |
- self.objectWillChange.send() |
|
| 72 |
+ appendPoint(timestamp: timestamp, value: value, kind: .sample) |
|
| 73 |
+ } |
|
| 74 |
+ |
|
| 75 |
+ func addDiscontinuity(timestamp: Date) {
|
|
| 76 |
+ guard !points.isEmpty else { return }
|
|
| 77 |
+ guard points.last?.isDiscontinuity == false else { return }
|
|
| 78 |
+ appendPoint(timestamp: timestamp, value: points.last?.value ?? 0, kind: .discontinuity) |
|
| 41 | 79 |
} |
| 42 | 80 |
|
| 43 |
- func reset() {
|
|
| 81 |
+ func resetSeries() {
|
|
| 44 | 82 |
points.removeAll() |
| 45 | 83 |
context.reset() |
| 46 | 84 |
self.objectWillChange.send() |
@@ -51,12 +89,9 @@ class Measurements : ObservableObject {
|
||
| 51 | 89 |
.filter { $0.timestamp >= cutoff }
|
| 52 | 90 |
.enumerated() |
| 53 | 91 |
.map { index, point in
|
| 54 |
- Measurement.Point(id: index, timestamp: point.timestamp, value: point.value) |
|
| 92 |
+ Measurement.Point(id: index, timestamp: point.timestamp, value: point.value, kind: point.kind) |
|
| 55 | 93 |
} |
| 56 |
- context.reset() |
|
| 57 |
- for point in points {
|
|
| 58 |
- context.include(point: point.point()) |
|
| 59 |
- } |
|
| 94 |
+ rebuildContext() |
|
| 60 | 95 |
self.objectWillChange.send() |
| 61 | 96 |
} |
| 62 | 97 |
} |
@@ -65,24 +100,43 @@ class Measurements : ObservableObject {
|
||
| 65 | 100 |
@Published var voltage = Measurement() |
| 66 | 101 |
@Published var current = Measurement() |
| 67 | 102 |
|
| 68 |
- private var lastPointTimestamp = 0 |
|
| 103 |
+ private var pendingBucketSecond: Int? |
|
| 104 |
+ private var pendingBucketTimestamp: Date? |
|
| 69 | 105 |
|
| 70 | 106 |
private var itemsInSum: Double = 0 |
| 71 | 107 |
private var powerSum: Double = 0 |
| 72 | 108 |
private var voltageSum: Double = 0 |
| 73 | 109 |
private var currentSum: Double = 0 |
| 74 | 110 |
|
| 75 |
- func reset() {
|
|
| 76 |
- power.reset() |
|
| 77 |
- voltage.reset() |
|
| 78 |
- current.reset() |
|
| 79 |
- lastPointTimestamp = 0 |
|
| 111 |
+ private func resetPendingAggregation() {
|
|
| 112 |
+ pendingBucketSecond = nil |
|
| 113 |
+ pendingBucketTimestamp = nil |
|
| 80 | 114 |
itemsInSum = 0 |
| 81 | 115 |
powerSum = 0 |
| 82 | 116 |
voltageSum = 0 |
| 83 | 117 |
currentSum = 0 |
| 118 |
+ } |
|
| 119 |
+ |
|
| 120 |
+ private func flushPendingValues() {
|
|
| 121 |
+ guard let pendingBucketTimestamp, itemsInSum > 0 else { return }
|
|
| 122 |
+ self.power.addPoint(timestamp: pendingBucketTimestamp, value: powerSum / itemsInSum) |
|
| 123 |
+ self.voltage.addPoint(timestamp: pendingBucketTimestamp, value: voltageSum / itemsInSum) |
|
| 124 |
+ self.current.addPoint(timestamp: pendingBucketTimestamp, value: currentSum / itemsInSum) |
|
| 125 |
+ resetPendingAggregation() |
|
| 84 | 126 |
self.objectWillChange.send() |
| 85 | 127 |
} |
| 128 |
+ |
|
| 129 |
+ func resetSeries() {
|
|
| 130 |
+ power.resetSeries() |
|
| 131 |
+ voltage.resetSeries() |
|
| 132 |
+ current.resetSeries() |
|
| 133 |
+ resetPendingAggregation() |
|
| 134 |
+ self.objectWillChange.send() |
|
| 135 |
+ } |
|
| 136 |
+ |
|
| 137 |
+ func reset() {
|
|
| 138 |
+ resetSeries() |
|
| 139 |
+ } |
|
| 86 | 140 |
|
| 87 | 141 |
func remove(at idx: Int) {
|
| 88 | 142 |
power.removeValue(index: idx) |
@@ -92,35 +146,40 @@ class Measurements : ObservableObject {
|
||
| 92 | 146 |
} |
| 93 | 147 |
|
| 94 | 148 |
func trim(before cutoff: Date) {
|
| 149 |
+ flushPendingValues() |
|
| 95 | 150 |
power.trim(before: cutoff) |
| 96 | 151 |
voltage.trim(before: cutoff) |
| 97 | 152 |
current.trim(before: cutoff) |
| 98 | 153 |
self.objectWillChange.send() |
| 99 | 154 |
} |
| 100 | 155 |
|
| 101 |
- |
|
| 102 |
- |
|
| 103 | 156 |
func addValues(timestamp: Date, power: Double, voltage: Double, current: Double) {
|
| 104 | 157 |
let valuesTimestamp = timestamp.timeIntervalSinceReferenceDate.intValue |
| 105 |
- if lastPointTimestamp == 0 {
|
|
| 106 |
- lastPointTimestamp = valuesTimestamp |
|
| 107 |
- } |
|
| 108 |
- if lastPointTimestamp == valuesTimestamp {
|
|
| 158 |
+ |
|
| 159 |
+ if pendingBucketSecond == valuesTimestamp {
|
|
| 160 |
+ pendingBucketTimestamp = timestamp |
|
| 109 | 161 |
itemsInSum += 1 |
| 110 | 162 |
powerSum += power |
| 111 | 163 |
voltageSum += voltage |
| 112 | 164 |
currentSum += current |
| 165 |
+ return |
|
| 113 | 166 |
} |
| 114 |
- else {
|
|
| 115 |
- self.power.addPoint( timestamp: timestamp, value: powerSum / itemsInSum ) |
|
| 116 |
- self.voltage.addPoint( timestamp: timestamp, value: voltageSum / itemsInSum ) |
|
| 117 |
- self.current.addPoint( timestamp: timestamp, value: currentSum / itemsInSum ) |
|
| 118 |
- lastPointTimestamp = valuesTimestamp |
|
| 119 |
- itemsInSum = 1 |
|
| 120 |
- powerSum = power |
|
| 121 |
- voltageSum = voltage |
|
| 122 |
- currentSum = current |
|
| 123 |
- self.objectWillChange.send() |
|
| 124 |
- } |
|
| 167 |
+ |
|
| 168 |
+ flushPendingValues() |
|
| 169 |
+ |
|
| 170 |
+ pendingBucketSecond = valuesTimestamp |
|
| 171 |
+ pendingBucketTimestamp = timestamp |
|
| 172 |
+ itemsInSum = 1 |
|
| 173 |
+ powerSum = power |
|
| 174 |
+ voltageSum = voltage |
|
| 175 |
+ currentSum = current |
|
| 176 |
+ } |
|
| 177 |
+ |
|
| 178 |
+ func markDiscontinuity(at timestamp: Date) {
|
|
| 179 |
+ flushPendingValues() |
|
| 180 |
+ power.addDiscontinuity(timestamp: timestamp) |
|
| 181 |
+ voltage.addDiscontinuity(timestamp: timestamp) |
|
| 182 |
+ current.addDiscontinuity(timestamp: timestamp) |
|
| 183 |
+ self.objectWillChange.send() |
|
| 125 | 184 |
} |
| 126 | 185 |
} |
@@ -107,6 +107,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 107 | 107 |
break |
| 108 | 108 |
case .peripheralNotConnected: |
| 109 | 109 |
cancelPendingDataDumpRequest(reason: "peripheral disconnected") |
| 110 |
+ handleMeasurementDiscontinuity(at: Date()) |
|
| 110 | 111 |
if !commandQueue.isEmpty {
|
| 111 | 112 |
track("\(name) - Clearing \(commandQueue.count) queued commands after disconnect")
|
| 112 | 113 |
commandQueue.removeAll() |
@@ -584,6 +585,15 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 584 | 585 |
appData.noteMeterConnected(at: date, macAddress: btSerial.macAddress.description) |
| 585 | 586 |
} |
| 586 | 587 |
|
| 588 |
+ private func handleMeasurementDiscontinuity(at timestamp: Date) {
|
|
| 589 |
+ measurements.markDiscontinuity(at: timestamp) |
|
| 590 |
+ |
|
| 591 |
+ guard chargeRecordState == .active else { return }
|
|
| 592 |
+ chargeRecordLastTimestamp = nil |
|
| 593 |
+ chargeRecordLastCurrent = 0 |
|
| 594 |
+ chargeRecordLastPower = 0 |
|
| 595 |
+ } |
|
| 596 |
+ |
|
| 587 | 597 |
private func cancelPendingDataDumpRequest(reason: String) {
|
| 588 | 598 |
guard let pendingDataDumpWorkItem else { return }
|
| 589 | 599 |
track("\(name) - Cancel scheduled data request (\(reason))")
|
@@ -9,27 +9,117 @@ |
||
| 9 | 9 |
import SwiftUI |
| 10 | 10 |
|
| 11 | 11 |
struct MeasurementChartView: View {
|
| 12 |
+ private enum SeriesKind {
|
|
| 13 |
+ case power |
|
| 14 |
+ case voltage |
|
| 15 |
+ case current |
|
| 16 |
+ |
|
| 17 |
+ var unit: String {
|
|
| 18 |
+ switch self {
|
|
| 19 |
+ case .power: return "W" |
|
| 20 |
+ case .voltage: return "V" |
|
| 21 |
+ case .current: return "A" |
|
| 22 |
+ } |
|
| 23 |
+ } |
|
| 24 |
+ |
|
| 25 |
+ var tint: Color {
|
|
| 26 |
+ switch self {
|
|
| 27 |
+ case .power: return .red |
|
| 28 |
+ case .voltage: return .green |
|
| 29 |
+ case .current: return .blue |
|
| 30 |
+ } |
|
| 31 |
+ } |
|
| 32 |
+ } |
|
| 33 |
+ |
|
| 34 |
+ private struct SeriesData {
|
|
| 35 |
+ let kind: SeriesKind |
|
| 36 |
+ let points: [Measurements.Measurement.Point] |
|
| 37 |
+ let samplePoints: [Measurements.Measurement.Point] |
|
| 38 |
+ let context: ChartContext |
|
| 39 |
+ let autoLowerBound: Double |
|
| 40 |
+ let autoUpperBound: Double |
|
| 41 |
+ let maximumSampleValue: Double? |
|
| 42 |
+ } |
|
| 43 |
+ |
|
| 12 | 44 |
private let minimumTimeSpan: TimeInterval = 1 |
| 13 | 45 |
private let minimumVoltageSpan = 0.5 |
| 14 | 46 |
private let minimumCurrentSpan = 0.5 |
| 15 | 47 |
private let minimumPowerSpan = 0.5 |
| 16 |
- private let axisColumnWidth: CGFloat = 46 |
|
| 17 |
- private let chartSectionSpacing: CGFloat = 8 |
|
| 18 |
- private let xAxisHeight: CGFloat = 28 |
|
| 48 |
+ private let axisSwipeThreshold: CGFloat = 12 |
|
| 49 |
+ private let defaultEmptyChartTimeSpan: TimeInterval = 60 |
|
| 50 |
+ |
|
| 51 |
+ let compactLayout: Bool |
|
| 52 |
+ let availableSize: CGSize |
|
| 19 | 53 |
|
| 20 | 54 |
@EnvironmentObject private var measurements: Measurements |
| 55 |
+ @Environment(\.horizontalSizeClass) private var horizontalSizeClass |
|
| 56 |
+ @Environment(\.verticalSizeClass) private var verticalSizeClass |
|
| 21 | 57 |
var timeRange: ClosedRange<Date>? = nil |
| 22 | 58 |
|
| 23 | 59 |
@State var displayVoltage: Bool = false |
| 24 | 60 |
@State var displayCurrent: Bool = false |
| 25 | 61 |
@State var displayPower: Bool = true |
| 62 |
+ @State private var showResetConfirmation: Bool = false |
|
| 63 |
+ @State private var chartNow: Date = Date() |
|
| 64 |
+ @State private var pinOrigin: Bool = false |
|
| 65 |
+ @State private var useSharedOrigin: Bool = false |
|
| 66 |
+ @State private var sharedAxisOrigin: Double = 0 |
|
| 67 |
+ @State private var powerAxisOrigin: Double = 0 |
|
| 68 |
+ @State private var voltageAxisOrigin: Double = 0 |
|
| 69 |
+ @State private var currentAxisOrigin: Double = 0 |
|
| 26 | 70 |
let xLabels: Int = 4 |
| 27 | 71 |
let yLabels: Int = 4 |
| 28 | 72 |
|
| 73 |
+ init( |
|
| 74 |
+ compactLayout: Bool = false, |
|
| 75 |
+ availableSize: CGSize = .zero, |
|
| 76 |
+ timeRange: ClosedRange<Date>? = nil |
|
| 77 |
+ ) {
|
|
| 78 |
+ self.compactLayout = compactLayout |
|
| 79 |
+ self.availableSize = availableSize |
|
| 80 |
+ self.timeRange = timeRange |
|
| 81 |
+ } |
|
| 82 |
+ |
|
| 83 |
+ private var axisColumnWidth: CGFloat {
|
|
| 84 |
+ compactLayout ? 38 : 46 |
|
| 85 |
+ } |
|
| 86 |
+ |
|
| 87 |
+ private var chartSectionSpacing: CGFloat {
|
|
| 88 |
+ compactLayout ? 6 : 8 |
|
| 89 |
+ } |
|
| 90 |
+ |
|
| 91 |
+ private var xAxisHeight: CGFloat {
|
|
| 92 |
+ compactLayout ? 24 : 28 |
|
| 93 |
+ } |
|
| 94 |
+ |
|
| 95 |
+ private var plotSectionHeight: CGFloat {
|
|
| 96 |
+ if availableSize == .zero {
|
|
| 97 |
+ return compactLayout ? 260 : 340 |
|
| 98 |
+ } |
|
| 99 |
+ |
|
| 100 |
+ if compactLayout {
|
|
| 101 |
+ return min(max(availableSize.height * 0.36, 240), 300) |
|
| 102 |
+ } |
|
| 103 |
+ |
|
| 104 |
+ return min(max(availableSize.height * 0.5, 300), 440) |
|
| 105 |
+ } |
|
| 106 |
+ |
|
| 107 |
+ private var stackedToolbarLayout: Bool {
|
|
| 108 |
+ if availableSize.width > 0 {
|
|
| 109 |
+ return availableSize.width < 640 |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 112 |
+ return horizontalSizeClass == .compact && verticalSizeClass != .compact |
|
| 113 |
+ } |
|
| 114 |
+ |
|
| 115 |
+ private var showsLabeledOriginControls: Bool {
|
|
| 116 |
+ !compactLayout && !stackedToolbarLayout |
|
| 117 |
+ } |
|
| 118 |
+ |
|
| 29 | 119 |
var body: some View {
|
| 30 |
- let powerSeries = series(for: measurements.power, minimumYSpan: minimumPowerSpan) |
|
| 31 |
- let voltageSeries = series(for: measurements.voltage, minimumYSpan: minimumVoltageSpan) |
|
| 32 |
- let currentSeries = series(for: measurements.current, minimumYSpan: minimumCurrentSpan) |
|
| 120 |
+ let powerSeries = series(for: measurements.power, kind: .power, minimumYSpan: minimumPowerSpan) |
|
| 121 |
+ let voltageSeries = series(for: measurements.voltage, kind: .voltage, minimumYSpan: minimumVoltageSpan) |
|
| 122 |
+ let currentSeries = series(for: measurements.current, kind: .current, minimumYSpan: minimumCurrentSpan) |
|
| 33 | 123 |
let primarySeries = displayedPrimarySeries( |
| 34 | 124 |
powerSeries: powerSeries, |
| 35 | 125 |
voltageSeries: voltageSeries, |
@@ -39,10 +129,13 @@ struct MeasurementChartView: View {
|
||
| 39 | 129 |
Group {
|
| 40 | 130 |
if let primarySeries {
|
| 41 | 131 |
VStack(alignment: .leading, spacing: 12) {
|
| 42 |
- chartToggleBar |
|
| 132 |
+ chartToggleBar( |
|
| 133 |
+ voltageSeries: voltageSeries, |
|
| 134 |
+ currentSeries: currentSeries |
|
| 135 |
+ ) |
|
| 43 | 136 |
|
| 44 | 137 |
GeometryReader { geometry in
|
| 45 |
- let plotHeight = max(geometry.size.height - xAxisHeight, 140) |
|
| 138 |
+ let plotHeight = max(geometry.size.height - xAxisHeight, compactLayout ? 180 : 220) |
|
| 46 | 139 |
|
| 47 | 140 |
VStack(spacing: 6) {
|
| 48 | 141 |
HStack(spacing: chartSectionSpacing) {
|
@@ -63,6 +156,7 @@ struct MeasurementChartView: View {
|
||
| 63 | 156 |
|
| 64 | 157 |
horizontalGuides(context: primarySeries.context) |
| 65 | 158 |
verticalGuides(context: primarySeries.context) |
| 159 |
+ discontinuityMarkers(points: primarySeries.points, context: primarySeries.context) |
|
| 66 | 160 |
renderedChart( |
| 67 | 161 |
powerSeries: powerSeries, |
| 68 | 162 |
voltageSeries: voltageSeries, |
@@ -87,85 +181,268 @@ struct MeasurementChartView: View {
|
||
| 87 | 181 |
} |
| 88 | 182 |
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
| 89 | 183 |
} |
| 184 |
+ .frame(height: plotSectionHeight) |
|
| 90 | 185 |
} |
| 91 | 186 |
} else {
|
| 92 | 187 |
VStack(alignment: .leading, spacing: 12) {
|
| 93 |
- chartToggleBar |
|
| 94 |
- Text("Nothing to show!")
|
|
| 188 |
+ chartToggleBar( |
|
| 189 |
+ voltageSeries: voltageSeries, |
|
| 190 |
+ currentSeries: currentSeries |
|
| 191 |
+ ) |
|
| 192 |
+ Text("Select at least one measurement series.")
|
|
| 95 | 193 |
.foregroundColor(.secondary) |
| 96 | 194 |
} |
| 97 | 195 |
} |
| 98 | 196 |
} |
| 99 | 197 |
.font(.footnote) |
| 100 | 198 |
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
| 199 |
+ .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
|
|
| 200 |
+ guard timeRange == nil else { return }
|
|
| 201 |
+ chartNow = now |
|
| 202 |
+ } |
|
| 101 | 203 |
} |
| 102 | 204 |
|
| 103 |
- private var chartToggleBar: some View {
|
|
| 104 |
- HStack(spacing: 8) {
|
|
| 105 |
- Button(action: {
|
|
| 106 |
- self.displayVoltage.toggle() |
|
| 107 |
- if self.displayVoltage {
|
|
| 108 |
- self.displayPower = false |
|
| 205 |
+ private func chartToggleBar( |
|
| 206 |
+ voltageSeries: SeriesData, |
|
| 207 |
+ currentSeries: SeriesData |
|
| 208 |
+ ) -> some View {
|
|
| 209 |
+ let condensedLayout = compactLayout || verticalSizeClass == .compact |
|
| 210 |
+ |
|
| 211 |
+ return VStack(alignment: .leading, spacing: condensedLayout ? 8 : 10) {
|
|
| 212 |
+ seriesToggleRow(condensedLayout: condensedLayout) |
|
| 213 |
+ |
|
| 214 |
+ if stackedToolbarLayout {
|
|
| 215 |
+ HStack(alignment: .center, spacing: 10) {
|
|
| 216 |
+ originControlsRow( |
|
| 217 |
+ voltageSeries: voltageSeries, |
|
| 218 |
+ currentSeries: currentSeries, |
|
| 219 |
+ condensedLayout: condensedLayout |
|
| 220 |
+ ) |
|
| 221 |
+ |
|
| 222 |
+ Spacer(minLength: 0) |
|
| 223 |
+ |
|
| 224 |
+ resetBufferButton(condensedLayout: condensedLayout) |
|
| 109 | 225 |
} |
| 110 |
- }) { Text("Voltage") }
|
|
| 111 |
- .asEnableFeatureButton(state: displayVoltage) |
|
| 226 |
+ } else {
|
|
| 227 |
+ HStack(alignment: .center, spacing: 16) {
|
|
| 228 |
+ originControlsRow( |
|
| 229 |
+ voltageSeries: voltageSeries, |
|
| 230 |
+ currentSeries: currentSeries, |
|
| 231 |
+ condensedLayout: condensedLayout |
|
| 232 |
+ ) |
|
| 233 |
+ |
|
| 234 |
+ Spacer(minLength: 0) |
|
| 112 | 235 |
|
| 113 |
- Button(action: {
|
|
| 114 |
- self.displayCurrent.toggle() |
|
| 115 |
- if self.displayCurrent {
|
|
| 116 |
- self.displayPower = false |
|
| 236 |
+ resetBufferButton(condensedLayout: condensedLayout) |
|
| 117 | 237 |
} |
| 118 |
- }) { Text("Current") }
|
|
| 119 |
- .asEnableFeatureButton(state: displayCurrent) |
|
| 120 |
- |
|
| 121 |
- Button(action: {
|
|
| 122 |
- self.displayPower.toggle() |
|
| 123 |
- if self.displayPower {
|
|
| 124 |
- self.displayCurrent = false |
|
| 125 |
- self.displayVoltage = false |
|
| 238 |
+ } |
|
| 239 |
+ } |
|
| 240 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 241 |
+ } |
|
| 242 |
+ |
|
| 243 |
+ private func seriesToggleRow(condensedLayout: Bool) -> some View {
|
|
| 244 |
+ HStack(spacing: condensedLayout ? 6 : 8) {
|
|
| 245 |
+ seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
|
|
| 246 |
+ displayVoltage.toggle() |
|
| 247 |
+ if displayVoltage {
|
|
| 248 |
+ displayPower = false |
|
| 249 |
+ } |
|
| 250 |
+ } |
|
| 251 |
+ |
|
| 252 |
+ seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
|
|
| 253 |
+ displayCurrent.toggle() |
|
| 254 |
+ if displayCurrent {
|
|
| 255 |
+ displayPower = false |
|
| 126 | 256 |
} |
| 127 |
- }) { Text("Power") }
|
|
| 128 |
- .asEnableFeatureButton(state: displayPower) |
|
| 257 |
+ } |
|
| 258 |
+ |
|
| 259 |
+ seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
|
|
| 260 |
+ displayPower.toggle() |
|
| 261 |
+ if displayPower {
|
|
| 262 |
+ displayCurrent = false |
|
| 263 |
+ displayVoltage = false |
|
| 264 |
+ } |
|
| 265 |
+ } |
|
| 266 |
+ } |
|
| 267 |
+ } |
|
| 268 |
+ |
|
| 269 |
+ private func originControlsRow( |
|
| 270 |
+ voltageSeries: SeriesData, |
|
| 271 |
+ currentSeries: SeriesData, |
|
| 272 |
+ condensedLayout: Bool |
|
| 273 |
+ ) -> some View {
|
|
| 274 |
+ HStack(spacing: condensedLayout ? 8 : 10) {
|
|
| 275 |
+ symbolControlChip( |
|
| 276 |
+ systemImage: "equal.circle", |
|
| 277 |
+ enabled: supportsSharedOrigin, |
|
| 278 |
+ active: useSharedOrigin && supportsSharedOrigin, |
|
| 279 |
+ condensedLayout: condensedLayout, |
|
| 280 |
+ showsLabel: showsLabeledOriginControls, |
|
| 281 |
+ label: "Match Y Origin", |
|
| 282 |
+ accessibilityLabel: "Match Y origin" |
|
| 283 |
+ ) {
|
|
| 284 |
+ toggleSharedOrigin( |
|
| 285 |
+ voltageSeries: voltageSeries, |
|
| 286 |
+ currentSeries: currentSeries |
|
| 287 |
+ ) |
|
| 288 |
+ } |
|
| 289 |
+ |
|
| 290 |
+ symbolControlChip( |
|
| 291 |
+ systemImage: pinOrigin ? "pin.fill" : "pin.slash", |
|
| 292 |
+ enabled: true, |
|
| 293 |
+ active: pinOrigin, |
|
| 294 |
+ condensedLayout: condensedLayout, |
|
| 295 |
+ showsLabel: showsLabeledOriginControls, |
|
| 296 |
+ label: pinOrigin ? "Origin Locked" : "Origin Auto", |
|
| 297 |
+ accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin" |
|
| 298 |
+ ) {
|
|
| 299 |
+ togglePinnedOrigin( |
|
| 300 |
+ voltageSeries: voltageSeries, |
|
| 301 |
+ currentSeries: currentSeries |
|
| 302 |
+ ) |
|
| 303 |
+ } |
|
| 304 |
+ |
|
| 305 |
+ symbolControlChip( |
|
| 306 |
+ systemImage: "0.circle", |
|
| 307 |
+ enabled: true, |
|
| 308 |
+ active: pinnedOriginIsZero, |
|
| 309 |
+ condensedLayout: condensedLayout, |
|
| 310 |
+ showsLabel: showsLabeledOriginControls, |
|
| 311 |
+ label: "Origin 0", |
|
| 312 |
+ accessibilityLabel: "Set origin to zero" |
|
| 313 |
+ ) {
|
|
| 314 |
+ setVisibleOriginsToZero() |
|
| 315 |
+ } |
|
| 316 |
+ } |
|
| 317 |
+ } |
|
| 318 |
+ |
|
| 319 |
+ private func seriesToggleButton( |
|
| 320 |
+ title: String, |
|
| 321 |
+ isOn: Bool, |
|
| 322 |
+ condensedLayout: Bool, |
|
| 323 |
+ action: @escaping () -> Void |
|
| 324 |
+ ) -> some View {
|
|
| 325 |
+ Button(action: action) {
|
|
| 326 |
+ Text(title) |
|
| 327 |
+ .font((condensedLayout ? Font.callout : .body).weight(.semibold)) |
|
| 328 |
+ .lineLimit(1) |
|
| 329 |
+ .minimumScaleFactor(0.82) |
|
| 330 |
+ .foregroundColor(isOn ? .white : .blue) |
|
| 331 |
+ .padding(.horizontal, condensedLayout ? 10 : 12) |
|
| 332 |
+ .padding(.vertical, condensedLayout ? 7 : 8) |
|
| 333 |
+ .frame(minWidth: condensedLayout ? 0 : 84) |
|
| 334 |
+ .frame(maxWidth: stackedToolbarLayout ? .infinity : nil) |
|
| 335 |
+ .background( |
|
| 336 |
+ RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous) |
|
| 337 |
+ .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12)) |
|
| 338 |
+ ) |
|
| 339 |
+ .overlay( |
|
| 340 |
+ RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous) |
|
| 341 |
+ .stroke(Color.blue, lineWidth: 1.5) |
|
| 342 |
+ ) |
|
| 343 |
+ } |
|
| 344 |
+ .buttonStyle(.plain) |
|
| 345 |
+ } |
|
| 346 |
+ |
|
| 347 |
+ private func symbolControlChip( |
|
| 348 |
+ systemImage: String, |
|
| 349 |
+ enabled: Bool, |
|
| 350 |
+ active: Bool, |
|
| 351 |
+ condensedLayout: Bool, |
|
| 352 |
+ showsLabel: Bool, |
|
| 353 |
+ label: String, |
|
| 354 |
+ accessibilityLabel: String, |
|
| 355 |
+ action: @escaping () -> Void |
|
| 356 |
+ ) -> some View {
|
|
| 357 |
+ Button(action: {
|
|
| 358 |
+ action() |
|
| 359 |
+ }) {
|
|
| 360 |
+ Group {
|
|
| 361 |
+ if showsLabel {
|
|
| 362 |
+ Label(label, systemImage: systemImage) |
|
| 363 |
+ .font((condensedLayout ? Font.callout : .footnote).weight(.semibold)) |
|
| 364 |
+ .padding(.horizontal, condensedLayout ? 10 : 12) |
|
| 365 |
+ .padding(.vertical, condensedLayout ? 7 : 8) |
|
| 366 |
+ } else {
|
|
| 367 |
+ Image(systemName: systemImage) |
|
| 368 |
+ .font(.system(size: condensedLayout ? 15 : 16, weight: .semibold)) |
|
| 369 |
+ .frame(width: condensedLayout ? 34 : 38, height: condensedLayout ? 34 : 38) |
|
| 370 |
+ } |
|
| 371 |
+ } |
|
| 372 |
+ .background( |
|
| 373 |
+ Capsule(style: .continuous) |
|
| 374 |
+ .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10)) |
|
| 375 |
+ ) |
|
| 376 |
+ } |
|
| 377 |
+ .buttonStyle(.plain) |
|
| 378 |
+ .foregroundColor(enabled ? .primary : .secondary) |
|
| 379 |
+ .opacity(enabled ? 1 : 0.55) |
|
| 380 |
+ .accessibilityLabel(accessibilityLabel) |
|
| 381 |
+ } |
|
| 382 |
+ |
|
| 383 |
+ private func resetBufferButton(condensedLayout: Bool) -> some View {
|
|
| 384 |
+ Button(action: {
|
|
| 385 |
+ showResetConfirmation = true |
|
| 386 |
+ }) {
|
|
| 387 |
+ Label(condensedLayout ? "Reset" : "Reset Buffer", systemImage: "trash") |
|
| 388 |
+ .font((condensedLayout ? Font.callout : .footnote).weight(.semibold)) |
|
| 389 |
+ .padding(.horizontal, condensedLayout ? 14 : 16) |
|
| 390 |
+ .padding(.vertical, condensedLayout ? 10 : 11) |
|
| 391 |
+ } |
|
| 392 |
+ .buttonStyle(.plain) |
|
| 393 |
+ .foregroundColor(.white) |
|
| 394 |
+ .background( |
|
| 395 |
+ Capsule(style: .continuous) |
|
| 396 |
+ .fill(Color.red.opacity(0.8)) |
|
| 397 |
+ ) |
|
| 398 |
+ .fixedSize(horizontal: true, vertical: false) |
|
| 399 |
+ .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
|
|
| 400 |
+ Button("Reset series", role: .destructive) {
|
|
| 401 |
+ measurements.resetSeries() |
|
| 402 |
+ } |
|
| 403 |
+ Button("Cancel", role: .cancel) {}
|
|
| 129 | 404 |
} |
| 130 |
- .frame(maxWidth: .infinity, alignment: .center) |
|
| 131 | 405 |
} |
| 132 | 406 |
|
| 133 | 407 |
@ViewBuilder |
| 134 | 408 |
private func primaryAxisView( |
| 135 | 409 |
height: CGFloat, |
| 136 |
- powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 137 |
- voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 138 |
- currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext) |
|
| 410 |
+ powerSeries: SeriesData, |
|
| 411 |
+ voltageSeries: SeriesData, |
|
| 412 |
+ currentSeries: SeriesData |
|
| 139 | 413 |
) -> some View {
|
| 140 | 414 |
if displayPower {
|
| 141 | 415 |
yAxisLabelsView( |
| 142 | 416 |
height: height, |
| 143 | 417 |
context: powerSeries.context, |
| 144 |
- measurementUnit: "W", |
|
| 145 |
- tint: .red |
|
| 418 |
+ seriesKind: .power, |
|
| 419 |
+ measurementUnit: powerSeries.kind.unit, |
|
| 420 |
+ tint: powerSeries.kind.tint |
|
| 146 | 421 |
) |
| 147 | 422 |
} else if displayVoltage {
|
| 148 | 423 |
yAxisLabelsView( |
| 149 | 424 |
height: height, |
| 150 | 425 |
context: voltageSeries.context, |
| 151 |
- measurementUnit: "V", |
|
| 152 |
- tint: .green |
|
| 426 |
+ seriesKind: .voltage, |
|
| 427 |
+ measurementUnit: voltageSeries.kind.unit, |
|
| 428 |
+ tint: voltageSeries.kind.tint |
|
| 153 | 429 |
) |
| 154 | 430 |
} else if displayCurrent {
|
| 155 | 431 |
yAxisLabelsView( |
| 156 | 432 |
height: height, |
| 157 | 433 |
context: currentSeries.context, |
| 158 |
- measurementUnit: "A", |
|
| 159 |
- tint: .blue |
|
| 434 |
+ seriesKind: .current, |
|
| 435 |
+ measurementUnit: currentSeries.kind.unit, |
|
| 436 |
+ tint: currentSeries.kind.tint |
|
| 160 | 437 |
) |
| 161 | 438 |
} |
| 162 | 439 |
} |
| 163 | 440 |
|
| 164 | 441 |
@ViewBuilder |
| 165 | 442 |
private func renderedChart( |
| 166 |
- powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 167 |
- voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 168 |
- currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext) |
|
| 443 |
+ powerSeries: SeriesData, |
|
| 444 |
+ voltageSeries: SeriesData, |
|
| 445 |
+ currentSeries: SeriesData |
|
| 169 | 446 |
) -> some View {
|
| 170 | 447 |
if self.displayPower {
|
| 171 | 448 |
Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red) |
@@ -185,16 +462,17 @@ struct MeasurementChartView: View {
|
||
| 185 | 462 |
@ViewBuilder |
| 186 | 463 |
private func secondaryAxisView( |
| 187 | 464 |
height: CGFloat, |
| 188 |
- powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 189 |
- voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 190 |
- currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext) |
|
| 465 |
+ powerSeries: SeriesData, |
|
| 466 |
+ voltageSeries: SeriesData, |
|
| 467 |
+ currentSeries: SeriesData |
|
| 191 | 468 |
) -> some View {
|
| 192 | 469 |
if displayVoltage && displayCurrent {
|
| 193 | 470 |
yAxisLabelsView( |
| 194 | 471 |
height: height, |
| 195 | 472 |
context: currentSeries.context, |
| 196 |
- measurementUnit: "A", |
|
| 197 |
- tint: .blue |
|
| 473 |
+ seriesKind: .current, |
|
| 474 |
+ measurementUnit: currentSeries.kind.unit, |
|
| 475 |
+ tint: currentSeries.kind.tint |
|
| 198 | 476 |
) |
| 199 | 477 |
} else {
|
| 200 | 478 |
primaryAxisView( |
@@ -207,41 +485,317 @@ struct MeasurementChartView: View {
|
||
| 207 | 485 |
} |
| 208 | 486 |
|
| 209 | 487 |
private func displayedPrimarySeries( |
| 210 |
- powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 211 |
- voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 212 |
- currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext) |
|
| 213 |
- ) -> (points: [Measurements.Measurement.Point], context: ChartContext)? {
|
|
| 488 |
+ powerSeries: SeriesData, |
|
| 489 |
+ voltageSeries: SeriesData, |
|
| 490 |
+ currentSeries: SeriesData |
|
| 491 |
+ ) -> SeriesData? {
|
|
| 214 | 492 |
if displayPower {
|
| 215 |
- return powerSeries.points.isEmpty ? nil : powerSeries |
|
| 493 |
+ return powerSeries |
|
| 216 | 494 |
} |
| 217 | 495 |
if displayVoltage {
|
| 218 |
- return voltageSeries.points.isEmpty ? nil : voltageSeries |
|
| 496 |
+ return voltageSeries |
|
| 219 | 497 |
} |
| 220 | 498 |
if displayCurrent {
|
| 221 |
- return currentSeries.points.isEmpty ? nil : currentSeries |
|
| 499 |
+ return currentSeries |
|
| 222 | 500 |
} |
| 223 | 501 |
return nil |
| 224 | 502 |
} |
| 225 | 503 |
|
| 226 | 504 |
private func series( |
| 227 | 505 |
for measurement: Measurements.Measurement, |
| 506 |
+ kind: SeriesKind, |
|
| 228 | 507 |
minimumYSpan: Double |
| 229 |
- ) -> (points: [Measurements.Measurement.Point], context: ChartContext) {
|
|
| 508 |
+ ) -> SeriesData {
|
|
| 230 | 509 |
let points = measurement.points.filter { point in
|
| 231 | 510 |
guard let timeRange else { return true }
|
| 232 | 511 |
return timeRange.contains(point.timestamp) |
| 233 | 512 |
} |
| 513 |
+ let samplePoints = points.filter { $0.isSample }
|
|
| 234 | 514 |
let context = ChartContext() |
| 235 |
- for point in points {
|
|
| 236 |
- context.include(point: point.point()) |
|
| 515 |
+ |
|
| 516 |
+ let autoBounds = automaticYBounds( |
|
| 517 |
+ for: samplePoints, |
|
| 518 |
+ minimumYSpan: minimumYSpan |
|
| 519 |
+ ) |
|
| 520 |
+ let xBounds = xBounds(for: samplePoints) |
|
| 521 |
+ let lowerBound = resolvedLowerBound( |
|
| 522 |
+ for: kind, |
|
| 523 |
+ autoLowerBound: autoBounds.lowerBound |
|
| 524 |
+ ) |
|
| 525 |
+ let upperBound = resolvedUpperBound( |
|
| 526 |
+ for: kind, |
|
| 527 |
+ lowerBound: lowerBound, |
|
| 528 |
+ autoUpperBound: autoBounds.upperBound, |
|
| 529 |
+ maximumSampleValue: samplePoints.map(\.value).max(), |
|
| 530 |
+ minimumYSpan: minimumYSpan |
|
| 531 |
+ ) |
|
| 532 |
+ |
|
| 533 |
+ context.setBounds( |
|
| 534 |
+ xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970), |
|
| 535 |
+ xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970), |
|
| 536 |
+ yMin: CGFloat(lowerBound), |
|
| 537 |
+ yMax: CGFloat(upperBound) |
|
| 538 |
+ ) |
|
| 539 |
+ |
|
| 540 |
+ return SeriesData( |
|
| 541 |
+ kind: kind, |
|
| 542 |
+ points: points, |
|
| 543 |
+ samplePoints: samplePoints, |
|
| 544 |
+ context: context, |
|
| 545 |
+ autoLowerBound: autoBounds.lowerBound, |
|
| 546 |
+ autoUpperBound: autoBounds.upperBound, |
|
| 547 |
+ maximumSampleValue: samplePoints.map(\.value).max() |
|
| 548 |
+ ) |
|
| 549 |
+ } |
|
| 550 |
+ |
|
| 551 |
+ private var supportsSharedOrigin: Bool {
|
|
| 552 |
+ displayVoltage && displayCurrent && !displayPower |
|
| 553 |
+ } |
|
| 554 |
+ |
|
| 555 |
+ private var pinnedOriginIsZero: Bool {
|
|
| 556 |
+ if useSharedOrigin && supportsSharedOrigin {
|
|
| 557 |
+ return pinOrigin && sharedAxisOrigin == 0 |
|
| 237 | 558 |
} |
| 238 |
- if !points.isEmpty {
|
|
| 239 |
- context.ensureMinimumSize( |
|
| 240 |
- width: CGFloat(minimumTimeSpan), |
|
| 241 |
- height: CGFloat(minimumYSpan) |
|
| 242 |
- ) |
|
| 559 |
+ |
|
| 560 |
+ if displayPower {
|
|
| 561 |
+ return pinOrigin && powerAxisOrigin == 0 |
|
| 562 |
+ } |
|
| 563 |
+ |
|
| 564 |
+ let visibleOrigins = [ |
|
| 565 |
+ displayVoltage ? voltageAxisOrigin : nil, |
|
| 566 |
+ displayCurrent ? currentAxisOrigin : nil |
|
| 567 |
+ ] |
|
| 568 |
+ .compactMap { $0 }
|
|
| 569 |
+ |
|
| 570 |
+ guard !visibleOrigins.isEmpty else { return false }
|
|
| 571 |
+ return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
|
|
| 572 |
+ } |
|
| 573 |
+ |
|
| 574 |
+ private func toggleSharedOrigin( |
|
| 575 |
+ voltageSeries: SeriesData, |
|
| 576 |
+ currentSeries: SeriesData |
|
| 577 |
+ ) {
|
|
| 578 |
+ guard supportsSharedOrigin else { return }
|
|
| 579 |
+ |
|
| 580 |
+ if useSharedOrigin {
|
|
| 581 |
+ useSharedOrigin = false |
|
| 582 |
+ return |
|
| 583 |
+ } |
|
| 584 |
+ |
|
| 585 |
+ captureCurrentOrigins( |
|
| 586 |
+ voltageSeries: voltageSeries, |
|
| 587 |
+ currentSeries: currentSeries |
|
| 588 |
+ ) |
|
| 589 |
+ sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin) |
|
| 590 |
+ useSharedOrigin = true |
|
| 591 |
+ pinOrigin = true |
|
| 592 |
+ } |
|
| 593 |
+ |
|
| 594 |
+ private func togglePinnedOrigin( |
|
| 595 |
+ voltageSeries: SeriesData, |
|
| 596 |
+ currentSeries: SeriesData |
|
| 597 |
+ ) {
|
|
| 598 |
+ if pinOrigin {
|
|
| 599 |
+ pinOrigin = false |
|
| 600 |
+ return |
|
| 601 |
+ } |
|
| 602 |
+ |
|
| 603 |
+ captureCurrentOrigins( |
|
| 604 |
+ voltageSeries: voltageSeries, |
|
| 605 |
+ currentSeries: currentSeries |
|
| 606 |
+ ) |
|
| 607 |
+ pinOrigin = true |
|
| 608 |
+ } |
|
| 609 |
+ |
|
| 610 |
+ private func setVisibleOriginsToZero() {
|
|
| 611 |
+ if useSharedOrigin && supportsSharedOrigin {
|
|
| 612 |
+ sharedAxisOrigin = 0 |
|
| 613 |
+ voltageAxisOrigin = 0 |
|
| 614 |
+ currentAxisOrigin = 0 |
|
| 615 |
+ } else {
|
|
| 616 |
+ if displayPower {
|
|
| 617 |
+ powerAxisOrigin = 0 |
|
| 618 |
+ } |
|
| 619 |
+ if displayVoltage {
|
|
| 620 |
+ voltageAxisOrigin = 0 |
|
| 621 |
+ } |
|
| 622 |
+ if displayCurrent {
|
|
| 623 |
+ currentAxisOrigin = 0 |
|
| 624 |
+ } |
|
| 625 |
+ } |
|
| 626 |
+ |
|
| 627 |
+ pinOrigin = true |
|
| 628 |
+ } |
|
| 629 |
+ |
|
| 630 |
+ private func captureCurrentOrigins( |
|
| 631 |
+ voltageSeries: SeriesData, |
|
| 632 |
+ currentSeries: SeriesData |
|
| 633 |
+ ) {
|
|
| 634 |
+ powerAxisOrigin = displayedLowerBoundForSeries(.power) |
|
| 635 |
+ voltageAxisOrigin = voltageSeries.autoLowerBound |
|
| 636 |
+ currentAxisOrigin = currentSeries.autoLowerBound |
|
| 637 |
+ sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin) |
|
| 638 |
+ } |
|
| 639 |
+ |
|
| 640 |
+ private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
|
|
| 641 |
+ switch kind {
|
|
| 642 |
+ case .power: |
|
| 643 |
+ return pinOrigin ? powerAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.power), minimumYSpan: minimumPowerSpan).lowerBound |
|
| 644 |
+ case .voltage: |
|
| 645 |
+ if pinOrigin && useSharedOrigin && supportsSharedOrigin {
|
|
| 646 |
+ return sharedAxisOrigin |
|
| 647 |
+ } |
|
| 648 |
+ return pinOrigin ? voltageAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.voltage), minimumYSpan: minimumVoltageSpan).lowerBound |
|
| 649 |
+ case .current: |
|
| 650 |
+ if pinOrigin && useSharedOrigin && supportsSharedOrigin {
|
|
| 651 |
+ return sharedAxisOrigin |
|
| 652 |
+ } |
|
| 653 |
+ return pinOrigin ? currentAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.current), minimumYSpan: minimumCurrentSpan).lowerBound |
|
| 654 |
+ } |
|
| 655 |
+ } |
|
| 656 |
+ |
|
| 657 |
+ private func filteredSamplePoints(_ measurement: Measurements.Measurement) -> [Measurements.Measurement.Point] {
|
|
| 658 |
+ measurement.points.filter { point in
|
|
| 659 |
+ point.isSample && (timeRange?.contains(point.timestamp) ?? true) |
|
| 660 |
+ } |
|
| 661 |
+ } |
|
| 662 |
+ |
|
| 663 |
+ private func xBounds( |
|
| 664 |
+ for samplePoints: [Measurements.Measurement.Point] |
|
| 665 |
+ ) -> ClosedRange<Date> {
|
|
| 666 |
+ if let timeRange {
|
|
| 667 |
+ return timeRange |
|
| 668 |
+ } |
|
| 669 |
+ |
|
| 670 |
+ let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow) |
|
| 671 |
+ let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan) |
|
| 672 |
+ |
|
| 673 |
+ if upperBound.timeIntervalSince(lowerBound) >= minimumTimeSpan {
|
|
| 674 |
+ return lowerBound...upperBound |
|
| 675 |
+ } |
|
| 676 |
+ |
|
| 677 |
+ return upperBound.addingTimeInterval(-minimumTimeSpan)...upperBound |
|
| 678 |
+ } |
|
| 679 |
+ |
|
| 680 |
+ private func automaticYBounds( |
|
| 681 |
+ for samplePoints: [Measurements.Measurement.Point], |
|
| 682 |
+ minimumYSpan: Double |
|
| 683 |
+ ) -> (lowerBound: Double, upperBound: Double) {
|
|
| 684 |
+ let negativeAllowance = max(0.05, minimumYSpan * 0.08) |
|
| 685 |
+ |
|
| 686 |
+ guard |
|
| 687 |
+ let minimumSampleValue = samplePoints.map(\.value).min(), |
|
| 688 |
+ let maximumSampleValue = samplePoints.map(\.value).max() |
|
| 689 |
+ else {
|
|
| 690 |
+ return (0, minimumYSpan) |
|
| 243 | 691 |
} |
| 244 |
- return (points, context) |
|
| 692 |
+ |
|
| 693 |
+ var lowerBound = minimumSampleValue |
|
| 694 |
+ var upperBound = maximumSampleValue |
|
| 695 |
+ let currentSpan = upperBound - lowerBound |
|
| 696 |
+ |
|
| 697 |
+ if currentSpan < minimumYSpan {
|
|
| 698 |
+ let expansion = (minimumYSpan - currentSpan) / 2 |
|
| 699 |
+ lowerBound -= expansion |
|
| 700 |
+ upperBound += expansion |
|
| 701 |
+ } |
|
| 702 |
+ |
|
| 703 |
+ if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
|
|
| 704 |
+ let shift = -negativeAllowance - lowerBound |
|
| 705 |
+ lowerBound += shift |
|
| 706 |
+ upperBound += shift |
|
| 707 |
+ } |
|
| 708 |
+ |
|
| 709 |
+ let snappedLowerBound = snappedOriginValue(lowerBound) |
|
| 710 |
+ let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan) |
|
| 711 |
+ return (snappedLowerBound, resolvedUpperBound) |
|
| 712 |
+ } |
|
| 713 |
+ |
|
| 714 |
+ private func resolvedLowerBound( |
|
| 715 |
+ for kind: SeriesKind, |
|
| 716 |
+ autoLowerBound: Double |
|
| 717 |
+ ) -> Double {
|
|
| 718 |
+ guard pinOrigin else { return autoLowerBound }
|
|
| 719 |
+ |
|
| 720 |
+ if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
|
|
| 721 |
+ return sharedAxisOrigin |
|
| 722 |
+ } |
|
| 723 |
+ |
|
| 724 |
+ switch kind {
|
|
| 725 |
+ case .power: |
|
| 726 |
+ return powerAxisOrigin |
|
| 727 |
+ case .voltage: |
|
| 728 |
+ return voltageAxisOrigin |
|
| 729 |
+ case .current: |
|
| 730 |
+ return currentAxisOrigin |
|
| 731 |
+ } |
|
| 732 |
+ } |
|
| 733 |
+ |
|
| 734 |
+ private func resolvedUpperBound( |
|
| 735 |
+ for kind: SeriesKind, |
|
| 736 |
+ lowerBound: Double, |
|
| 737 |
+ autoUpperBound: Double, |
|
| 738 |
+ maximumSampleValue: Double?, |
|
| 739 |
+ minimumYSpan: Double |
|
| 740 |
+ ) -> Double {
|
|
| 741 |
+ guard pinOrigin else {
|
|
| 742 |
+ return autoUpperBound |
|
| 743 |
+ } |
|
| 744 |
+ |
|
| 745 |
+ return max( |
|
| 746 |
+ maximumSampleValue ?? lowerBound, |
|
| 747 |
+ lowerBound + minimumYSpan, |
|
| 748 |
+ autoUpperBound |
|
| 749 |
+ ) |
|
| 750 |
+ } |
|
| 751 |
+ |
|
| 752 |
+ private func adjustOrigin(for kind: SeriesKind, translationHeight: CGFloat) {
|
|
| 753 |
+ guard abs(translationHeight) >= axisSwipeThreshold else { return }
|
|
| 754 |
+ |
|
| 755 |
+ let delta = translationHeight < 0 ? 1.0 : -1.0 |
|
| 756 |
+ let baseline = displayedLowerBoundForSeries(kind) |
|
| 757 |
+ let proposedOrigin = snappedOriginValue(baseline + delta) |
|
| 758 |
+ |
|
| 759 |
+ if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
|
|
| 760 |
+ sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin()) |
|
| 761 |
+ } else {
|
|
| 762 |
+ switch kind {
|
|
| 763 |
+ case .power: |
|
| 764 |
+ powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power)) |
|
| 765 |
+ case .voltage: |
|
| 766 |
+ voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage)) |
|
| 767 |
+ case .current: |
|
| 768 |
+ currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current)) |
|
| 769 |
+ } |
|
| 770 |
+ } |
|
| 771 |
+ |
|
| 772 |
+ pinOrigin = true |
|
| 773 |
+ } |
|
| 774 |
+ |
|
| 775 |
+ private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
|
|
| 776 |
+ switch kind {
|
|
| 777 |
+ case .power: |
|
| 778 |
+ return snappedOriginValue(filteredSamplePoints(measurements.power).map(\.value).min() ?? 0) |
|
| 779 |
+ case .voltage: |
|
| 780 |
+ return snappedOriginValue(filteredSamplePoints(measurements.voltage).map(\.value).min() ?? 0) |
|
| 781 |
+ case .current: |
|
| 782 |
+ return snappedOriginValue(filteredSamplePoints(measurements.current).map(\.value).min() ?? 0) |
|
| 783 |
+ } |
|
| 784 |
+ } |
|
| 785 |
+ |
|
| 786 |
+ private func maximumVisibleSharedOrigin() -> Double {
|
|
| 787 |
+ min( |
|
| 788 |
+ maximumVisibleOrigin(for: .voltage), |
|
| 789 |
+ maximumVisibleOrigin(for: .current) |
|
| 790 |
+ ) |
|
| 791 |
+ } |
|
| 792 |
+ |
|
| 793 |
+ private func snappedOriginValue(_ value: Double) -> Double {
|
|
| 794 |
+ if value >= 0 {
|
|
| 795 |
+ return value.rounded(.down) |
|
| 796 |
+ } |
|
| 797 |
+ |
|
| 798 |
+ return value.rounded(.up) |
|
| 245 | 799 |
} |
| 246 | 800 |
|
| 247 | 801 |
private func yGuidePosition( |
@@ -330,9 +884,10 @@ struct MeasurementChartView: View {
|
||
| 330 | 884 |
} |
| 331 | 885 |
} |
| 332 | 886 |
|
| 333 |
- fileprivate func yAxisLabelsView( |
|
| 887 |
+ private func yAxisLabelsView( |
|
| 334 | 888 |
height: CGFloat, |
| 335 | 889 |
context: ChartContext, |
| 890 |
+ seriesKind: SeriesKind, |
|
| 336 | 891 |
measurementUnit: String, |
| 337 | 892 |
tint: Color |
| 338 | 893 |
) -> some View {
|
@@ -367,6 +922,12 @@ struct MeasurementChartView: View {
|
||
| 367 | 922 |
.fill(tint.opacity(0.14)) |
| 368 | 923 |
) |
| 369 | 924 |
.padding(.top, 6) |
| 925 |
+ |
|
| 926 |
+ Text("Y \(Int(displayedLowerBoundForSeries(seriesKind)))")
|
|
| 927 |
+ .font(.caption2.weight(.semibold)) |
|
| 928 |
+ .foregroundColor(.secondary) |
|
| 929 |
+ .padding(.bottom, 8) |
|
| 930 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) |
|
| 370 | 931 |
} |
| 371 | 932 |
} |
| 372 | 933 |
.frame(height: height) |
@@ -378,6 +939,13 @@ struct MeasurementChartView: View {
|
||
| 378 | 939 |
RoundedRectangle(cornerRadius: 16, style: .continuous) |
| 379 | 940 |
.stroke(tint.opacity(0.20), lineWidth: 1) |
| 380 | 941 |
) |
| 942 |
+ .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) |
|
| 943 |
+ .gesture( |
|
| 944 |
+ DragGesture(minimumDistance: axisSwipeThreshold) |
|
| 945 |
+ .onEnded { value in
|
|
| 946 |
+ adjustOrigin(for: seriesKind, translationHeight: value.translation.height) |
|
| 947 |
+ } |
|
| 948 |
+ ) |
|
| 381 | 949 |
} |
| 382 | 950 |
|
| 383 | 951 |
fileprivate func horizontalGuides(context: ChartContext) -> some View {
|
@@ -413,6 +981,27 @@ struct MeasurementChartView: View {
|
||
| 413 | 981 |
.stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4])) |
| 414 | 982 |
} |
| 415 | 983 |
} |
| 984 |
+ |
|
| 985 |
+ fileprivate func discontinuityMarkers( |
|
| 986 |
+ points: [Measurements.Measurement.Point], |
|
| 987 |
+ context: ChartContext |
|
| 988 |
+ ) -> some View {
|
|
| 989 |
+ GeometryReader { geometry in
|
|
| 990 |
+ Path { path in
|
|
| 991 |
+ for point in points where point.isDiscontinuity {
|
|
| 992 |
+ let markerX = context.placeInRect( |
|
| 993 |
+ point: CGPoint( |
|
| 994 |
+ x: point.timestamp.timeIntervalSince1970, |
|
| 995 |
+ y: context.origin.y |
|
| 996 |
+ ) |
|
| 997 |
+ ).x * geometry.size.width |
|
| 998 |
+ path.move(to: CGPoint(x: markerX, y: 0)) |
|
| 999 |
+ path.addLine(to: CGPoint(x: markerX, y: geometry.size.height)) |
|
| 1000 |
+ } |
|
| 1001 |
+ } |
|
| 1002 |
+ .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4])) |
|
| 1003 |
+ } |
|
| 1004 |
+ } |
|
| 416 | 1005 |
|
| 417 | 1006 |
} |
| 418 | 1007 |
|
@@ -437,15 +1026,38 @@ struct Chart : View {
|
||
| 437 | 1026 |
|
| 438 | 1027 |
fileprivate func path(geometry: GeometryProxy) -> Path {
|
| 439 | 1028 |
return Path { path in
|
| 440 |
- guard let first = points.first else { return }
|
|
| 441 |
- let firstPoint = context.placeInRect(point: first.point()) |
|
| 442 |
- path.move(to: CGPoint(x: firstPoint.x * geometry.size.width, y: firstPoint.y * geometry.size.height ) ) |
|
| 443 |
- for item in points.map({ context.placeInRect(point: $0.point()) }) {
|
|
| 444 |
- path.addLine(to: CGPoint(x: item.x * geometry.size.width, y: item.y * geometry.size.height ) ) |
|
| 1029 |
+ var firstSample: Measurements.Measurement.Point? |
|
| 1030 |
+ var lastSample: Measurements.Measurement.Point? |
|
| 1031 |
+ var needsMove = true |
|
| 1032 |
+ |
|
| 1033 |
+ for point in points {
|
|
| 1034 |
+ if point.isDiscontinuity {
|
|
| 1035 |
+ needsMove = true |
|
| 1036 |
+ continue |
|
| 1037 |
+ } |
|
| 1038 |
+ |
|
| 1039 |
+ let item = context.placeInRect(point: point.point()) |
|
| 1040 |
+ let renderedPoint = CGPoint( |
|
| 1041 |
+ x: item.x * geometry.size.width, |
|
| 1042 |
+ y: item.y * geometry.size.height |
|
| 1043 |
+ ) |
|
| 1044 |
+ |
|
| 1045 |
+ if firstSample == nil {
|
|
| 1046 |
+ firstSample = point |
|
| 1047 |
+ } |
|
| 1048 |
+ lastSample = point |
|
| 1049 |
+ |
|
| 1050 |
+ if needsMove {
|
|
| 1051 |
+ path.move(to: renderedPoint) |
|
| 1052 |
+ needsMove = false |
|
| 1053 |
+ } else {
|
|
| 1054 |
+ path.addLine(to: renderedPoint) |
|
| 1055 |
+ } |
|
| 445 | 1056 |
} |
| 446 |
- if self.areaChart {
|
|
| 447 |
- let lastPointX = context.placeInRect(point: CGPoint(x: points.last!.point().x, y: context.origin.y )) |
|
| 448 |
- let firstPointX = context.placeInRect(point: CGPoint(x: points.first!.point().x, y: context.origin.y )) |
|
| 1057 |
+ |
|
| 1058 |
+ if self.areaChart, let firstSample, let lastSample {
|
|
| 1059 |
+ let lastPointX = context.placeInRect(point: CGPoint(x: lastSample.point().x, y: context.origin.y )) |
|
| 1060 |
+ let firstPointX = context.placeInRect(point: CGPoint(x: firstSample.point().x, y: context.origin.y )) |
|
| 449 | 1061 |
path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) ) |
| 450 | 1062 |
path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) ) |
| 451 | 1063 |
// MARK: Nu e nevoie. Fill inchide automat calea |
@@ -40,6 +40,21 @@ struct MeterView: View {
|
||
| 40 | 40 |
floatingInset: 0 |
| 41 | 41 |
) |
| 42 | 42 |
|
| 43 |
+ static let portraitCompact = TabBarStyle( |
|
| 44 |
+ showsTitles: false, |
|
| 45 |
+ horizontalPadding: 16, |
|
| 46 |
+ topPadding: 10, |
|
| 47 |
+ bottomPadding: 8, |
|
| 48 |
+ chipHorizontalPadding: 12, |
|
| 49 |
+ chipVerticalPadding: 10, |
|
| 50 |
+ outerPadding: 6, |
|
| 51 |
+ maxWidth: 320, |
|
| 52 |
+ barBackgroundOpacity: 0.14, |
|
| 53 |
+ materialOpacity: 0.90, |
|
| 54 |
+ shadowOpacity: 0, |
|
| 55 |
+ floatingInset: 0 |
|
| 56 |
+ ) |
|
| 57 |
+ |
|
| 43 | 58 |
static let landscapeInline = TabBarStyle( |
| 44 | 59 |
showsTitles: true, |
| 45 | 60 |
horizontalPadding: 12, |
@@ -64,9 +79,9 @@ struct MeterView: View {
|
||
| 64 | 79 |
chipVerticalPadding: 11, |
| 65 | 80 |
outerPadding: 7, |
| 66 | 81 |
maxWidth: 260, |
| 67 |
- barBackgroundOpacity: 0.02, |
|
| 68 |
- materialOpacity: 0, |
|
| 69 |
- shadowOpacity: 0.18, |
|
| 82 |
+ barBackgroundOpacity: 0.16, |
|
| 83 |
+ materialOpacity: 0.88, |
|
| 84 |
+ shadowOpacity: 0.12, |
|
| 70 | 85 |
floatingInset: 12 |
| 71 | 86 |
) |
| 72 | 87 |
} |
@@ -116,7 +131,11 @@ struct MeterView: View {
|
||
| 116 | 131 |
GeometryReader { proxy in
|
| 117 | 132 |
let landscape = isLandscape(size: proxy.size) |
| 118 | 133 |
let usesOverlayTabBar = landscape && Self.isPhone |
| 119 |
- let tabBarStyle = tabBarStyle(for: landscape, usesOverlayTabBar: usesOverlayTabBar) |
|
| 134 |
+ let tabBarStyle = tabBarStyle( |
|
| 135 |
+ for: landscape, |
|
| 136 |
+ usesOverlayTabBar: usesOverlayTabBar, |
|
| 137 |
+ size: proxy.size |
|
| 138 |
+ ) |
|
| 120 | 139 |
|
| 121 | 140 |
VStack(spacing: 0) {
|
| 122 | 141 |
if Self.isMacIPadApp {
|
@@ -287,6 +306,8 @@ struct MeterView: View {
|
||
| 287 | 306 |
private func segmentedTabBar(style: TabBarStyle, showsConnectionAction: Bool = false) -> some View {
|
| 288 | 307 |
let isFloating = style.floatingInset > 0 |
| 289 | 308 |
let cornerRadius = style.showsTitles ? 14.0 : 22.0 |
| 309 |
+ let unselectedForegroundColor = isFloating ? Color.primary.opacity(0.86) : Color.primary |
|
| 310 |
+ let unselectedChipFill = isFloating ? Color.black.opacity(0.08) : Color.secondary.opacity(0.12) |
|
| 290 | 311 |
|
| 291 | 312 |
return HStack {
|
| 292 | 313 |
Spacer(minLength: 0) |
@@ -312,7 +333,7 @@ struct MeterView: View {
|
||
| 312 | 333 |
.foregroundColor( |
| 313 | 334 |
isSelected |
| 314 | 335 |
? .white |
| 315 |
- : (isFloating ? .white.opacity(0.82) : .primary) |
|
| 336 |
+ : unselectedForegroundColor |
|
| 316 | 337 |
) |
| 317 | 338 |
.padding(.horizontal, style.chipHorizontalPadding) |
| 318 | 339 |
.padding(.vertical, style.chipVerticalPadding) |
@@ -322,7 +343,7 @@ struct MeterView: View {
|
||
| 322 | 343 |
.fill( |
| 323 | 344 |
isSelected |
| 324 | 345 |
? meter.color.opacity(isFloating ? 0.94 : 1) |
| 325 |
- : (isFloating ? Color.white.opacity(0.045) : Color.secondary.opacity(0.12)) |
|
| 346 |
+ : unselectedChipFill |
|
| 326 | 347 |
) |
| 327 | 348 |
) |
| 328 | 349 |
} |
@@ -338,8 +359,8 @@ struct MeterView: View {
|
||
| 338 | 359 |
isFloating |
| 339 | 360 |
? LinearGradient( |
| 340 | 361 |
colors: [ |
| 341 |
- Color.white.opacity(0.14), |
|
| 342 |
- Color.white.opacity(0.06) |
|
| 362 |
+ Color.white.opacity(0.76), |
|
| 363 |
+ Color.white.opacity(0.52) |
|
| 343 | 364 |
], |
| 344 | 365 |
startPoint: .topLeading, |
| 345 | 366 |
endPoint: .bottomTrailing |
@@ -357,15 +378,14 @@ struct MeterView: View {
|
||
| 357 | 378 |
.overlay {
|
| 358 | 379 |
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) |
| 359 | 380 |
.stroke( |
| 360 |
- isFloating ? Color.white.opacity(0.10) : Color.clear, |
|
| 381 |
+ isFloating ? Color.black.opacity(0.08) : Color.clear, |
|
| 361 | 382 |
lineWidth: 1 |
| 362 | 383 |
) |
| 363 | 384 |
} |
| 364 | 385 |
.background {
|
| 365 |
- if !isFloating {
|
|
| 366 |
- RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) |
|
| 367 |
- .fill(.ultraThinMaterial) |
|
| 368 |
- } |
|
| 386 |
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) |
|
| 387 |
+ .fill(.ultraThinMaterial) |
|
| 388 |
+ .opacity(style.materialOpacity) |
|
| 369 | 389 |
} |
| 370 | 390 |
.shadow(color: Color.black.opacity(style.shadowOpacity), radius: isFloating ? 28 : 24, x: 0, y: isFloating ? 16 : 12) |
| 371 | 391 |
|
@@ -449,19 +469,7 @@ struct MeterView: View {
|
||
| 449 | 469 |
} |
| 450 | 470 |
|
| 451 | 471 |
private var availableMeterTabs: [MeterTab] {
|
| 452 |
- var tabs: [MeterTab] = [.home] |
|
| 453 |
- |
|
| 454 |
- if meter.operationalState == .dataIsAvailable {
|
|
| 455 |
- tabs.append(.live) |
|
| 456 |
- |
|
| 457 |
- if meter.measurements.power.context.isValid {
|
|
| 458 |
- tabs.append(.chart) |
|
| 459 |
- } |
|
| 460 |
- } |
|
| 461 |
- |
|
| 462 |
- tabs.append(.settings) |
|
| 463 |
- |
|
| 464 |
- return tabs |
|
| 472 |
+ [.home, .live, .chart, .settings] |
|
| 465 | 473 |
} |
| 466 | 474 |
|
| 467 | 475 |
private var displayedMeterTab: MeterTab {
|
@@ -498,7 +506,7 @@ struct MeterView: View {
|
||
| 498 | 506 |
size.width > size.height |
| 499 | 507 |
} |
| 500 | 508 |
|
| 501 |
- private func tabBarStyle(for landscape: Bool, usesOverlayTabBar: Bool) -> TabBarStyle {
|
|
| 509 |
+ private func tabBarStyle(for landscape: Bool, usesOverlayTabBar: Bool, size: CGSize) -> TabBarStyle {
|
|
| 502 | 510 |
if usesOverlayTabBar {
|
| 503 | 511 |
return .landscapeFloating |
| 504 | 512 |
} |
@@ -507,6 +515,10 @@ struct MeterView: View {
|
||
| 507 | 515 |
return .landscapeInline |
| 508 | 516 |
} |
| 509 | 517 |
|
| 518 |
+ if Self.isPhone && size.width < 390 {
|
|
| 519 |
+ return .portraitCompact |
|
| 520 |
+ } |
|
| 521 |
+ |
|
| 510 | 522 |
return .portrait |
| 511 | 523 |
} |
| 512 | 524 |
|
@@ -1,5 +1,5 @@ |
||
| 1 | 1 |
// |
| 2 |
-// AppHistorySheetView.swift |
|
| 2 |
+// MeasurementSeriesSheetView.swift |
|
| 3 | 3 |
// USB Meter |
| 4 | 4 |
// |
| 5 | 5 |
// Created by Bogdan Timofte on 13/04/2020. |
@@ -8,36 +8,38 @@ |
||
| 8 | 8 |
|
| 9 | 9 |
import SwiftUI |
| 10 | 10 |
|
| 11 |
-struct AppHistorySheetView: View {
|
|
| 11 |
+struct MeasurementSeriesSheetView: View {
|
|
| 12 | 12 |
|
| 13 | 13 |
@EnvironmentObject private var measurements: Measurements |
| 14 | 14 |
|
| 15 | 15 |
@Binding var visibility: Bool |
| 16 | 16 |
|
| 17 | 17 |
var body: some View {
|
| 18 |
+ let seriesPoints = measurements.power.samplePoints |
|
| 19 |
+ |
|
| 18 | 20 |
NavigationView {
|
| 19 | 21 |
ScrollView {
|
| 20 | 22 |
VStack(alignment: .leading, spacing: 14) {
|
| 21 | 23 |
VStack(alignment: .leading, spacing: 8) {
|
| 22 |
- Text("App History")
|
|
| 24 |
+ Text("Measurement Series")
|
|
| 23 | 25 |
.font(.system(.title3, design: .rounded).weight(.bold)) |
| 24 |
- Text("Local timeline captured by the app while connected to the meter.")
|
|
| 26 |
+ Text("Buffered measurement series captured from the meter for analysis, charts, and correlations.")
|
|
| 25 | 27 |
.font(.footnote) |
| 26 | 28 |
.foregroundColor(.secondary) |
| 27 | 29 |
} |
| 28 | 30 |
.padding(18) |
| 29 | 31 |
.meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24) |
| 30 | 32 |
|
| 31 |
- if measurements.power.points.isEmpty {
|
|
| 32 |
- Text("No history samples have been captured yet.")
|
|
| 33 |
+ if seriesPoints.isEmpty {
|
|
| 34 |
+ Text("No measurement samples have been captured yet.")
|
|
| 33 | 35 |
.font(.footnote) |
| 34 | 36 |
.foregroundColor(.secondary) |
| 35 | 37 |
.padding(18) |
| 36 | 38 |
.meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20) |
| 37 | 39 |
} else {
|
| 38 | 40 |
LazyVStack(spacing: 12) {
|
| 39 |
- ForEach(measurements.power.points) { point in
|
|
| 40 |
- AppHistorySampleView( |
|
| 41 |
+ ForEach(seriesPoints) { point in
|
|
| 42 |
+ MeasurementSeriesSampleView( |
|
| 41 | 43 |
power: point, |
| 42 | 44 |
voltage: measurements.voltage.points[point.id], |
| 43 | 45 |
current: measurements.current.points[point.id] |
@@ -58,12 +60,12 @@ struct AppHistorySheetView: View {
|
||
| 58 | 60 |
) |
| 59 | 61 |
.navigationBarItems( |
| 60 | 62 |
leading: Button("Done") { visibility.toggle() },
|
| 61 |
- trailing: Button("Clear") {
|
|
| 62 |
- measurements.reset() |
|
| 63 |
+ trailing: Button("Reset Series") {
|
|
| 64 |
+ measurements.resetSeries() |
|
| 63 | 65 |
} |
| 64 | 66 |
.foregroundColor(.red) |
| 65 | 67 |
) |
| 66 |
- .navigationBarTitle("App History", displayMode: .inline)
|
|
| 68 |
+ .navigationBarTitle("Measurement Series", displayMode: .inline)
|
|
| 67 | 69 |
} |
| 68 | 70 |
.navigationViewStyle(StackNavigationViewStyle()) |
| 69 | 71 |
} |
@@ -1,5 +1,5 @@ |
||
| 1 | 1 |
// |
| 2 |
-// AppHistorySampleView.swift |
|
| 2 |
+// MeasurementSeriesSampleView.swift |
|
| 3 | 3 |
// USB Meter |
| 4 | 4 |
// |
| 5 | 5 |
// Created by Bogdan Timofte on 13/04/2020. |
@@ -8,7 +8,7 @@ |
||
| 8 | 8 |
|
| 9 | 9 |
import SwiftUI |
| 10 | 10 |
|
| 11 |
-struct AppHistorySampleView: View {
|
|
| 11 |
+struct MeasurementSeriesSampleView: View {
|
|
| 12 | 12 |
|
| 13 | 13 |
var power: Measurements.Measurement.Point |
| 14 | 14 |
var voltage: Measurements.Measurement.Point |
@@ -15,22 +15,35 @@ struct MeterChartTabView: View {
|
||
| 15 | 15 |
private let pageVerticalPadding: CGFloat = 12 |
| 16 | 16 |
private let contentCardPadding: CGFloat = 16 |
| 17 | 17 |
|
| 18 |
+ private var prefersCompactPortraitLayout: Bool {
|
|
| 19 |
+ size.height < 760 || size.width < 380 |
|
| 20 |
+ } |
|
| 21 |
+ |
|
| 22 |
+ private var prefersCompactLandscapeLayout: Bool {
|
|
| 23 |
+ size.height < 430 |
|
| 24 |
+ } |
|
| 25 |
+ |
|
| 18 | 26 |
var body: some View {
|
| 19 | 27 |
Group {
|
| 20 | 28 |
if isLandscape {
|
| 21 | 29 |
landscapeFace {
|
| 22 |
- MeasurementChartView() |
|
| 30 |
+ MeasurementChartView( |
|
| 31 |
+ compactLayout: prefersCompactLandscapeLayout, |
|
| 32 |
+ availableSize: size |
|
| 33 |
+ ) |
|
| 23 | 34 |
.environmentObject(meter.measurements) |
| 24 |
- .frame(height: max(250, size.height - 44)) |
|
| 25 |
- .padding(contentCardPadding) |
|
| 26 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) |
|
| 35 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 36 |
+ .padding(10) |
|
| 37 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 27 | 38 |
.meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
| 28 | 39 |
} |
| 29 | 40 |
} else {
|
| 30 | 41 |
portraitFace {
|
| 31 |
- MeasurementChartView() |
|
| 42 |
+ MeasurementChartView( |
|
| 43 |
+ compactLayout: prefersCompactPortraitLayout, |
|
| 44 |
+ availableSize: size |
|
| 45 |
+ ) |
|
| 32 | 46 |
.environmentObject(meter.measurements) |
| 33 |
- .frame(minHeight: size.height / 3.4) |
|
| 34 | 47 |
.padding(contentCardPadding) |
| 35 | 48 |
.meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
| 36 | 49 |
} |
@@ -48,12 +61,10 @@ struct MeterChartTabView: View {
|
||
| 48 | 61 |
} |
| 49 | 62 |
|
| 50 | 63 |
private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
| 51 |
- ScrollView {
|
|
| 52 |
- content() |
|
| 53 |
- .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 54 |
- .padding(.horizontal, pageHorizontalPadding) |
|
| 55 |
- .padding(.vertical, pageVerticalPadding) |
|
| 56 |
- } |
|
| 64 |
+ content() |
|
| 65 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 66 |
+ .padding(.horizontal, pageHorizontalPadding) |
|
| 67 |
+ .padding(.vertical, pageVerticalPadding) |
|
| 57 | 68 |
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
| 58 | 69 |
} |
| 59 | 70 |
} |
@@ -139,11 +139,11 @@ struct MeterHomeTabView: View {
|
||
| 139 | 139 |
} |
| 140 | 140 |
|
| 141 | 141 |
actionStripDivider(height: currentActionHeight) |
| 142 |
- meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
|
|
| 142 |
+ meterSheetButton(icon: "waveform.path.ecg", title: "Measurement Series", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
|
|
| 143 | 143 |
measurementsViewVisibility.toggle() |
| 144 | 144 |
} |
| 145 | 145 |
.sheet(isPresented: $measurementsViewVisibility) {
|
| 146 |
- AppHistorySheetView(visibility: $measurementsViewVisibility) |
|
| 146 |
+ MeasurementSeriesSheetView(visibility: $measurementsViewVisibility) |
|
| 147 | 147 |
.environmentObject(meter.measurements) |
| 148 | 148 |
} |
| 149 | 149 |
} |
@@ -47,7 +47,7 @@ struct MeterConnectionToolbarButton: View {
|
||
| 47 | 47 |
} |
| 48 | 48 |
|
| 49 | 49 |
private var systemImage: String {
|
| 50 |
- connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill" |
|
| 50 |
+ connected ? "link.badge.minus" : "bolt.horizontal.circle.fill" |
|
| 51 | 51 |
} |
| 52 | 52 |
|
| 53 | 53 |
var body: some View {
|