Showing 11 changed files with 893 additions and 177 deletions
+11 -11
USB Meter.xcodeproj/project.pbxproj
@@ -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 */,
+10 -0
USB Meter/Model/ChartContext.swift
@@ -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)
+95 -36
USB Meter/Model/Measurements.swift
@@ -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
 }
+10 -0
USB Meter/Model/Meter.swift
@@ -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))")
+687 -75
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -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
+39 -27
USB Meter/Views/Meter/MeterView.swift
@@ -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
 
+13 -11
USB Meter/Views/Meter/Sheets/AppHistory/AppHistorySheetView.swift → USB Meter/Views/Meter/Sheets/MeasurementSeries/MeasurementSeriesSheetView.swift
@@ -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
     }
+2 -2
USB Meter/Views/Meter/Sheets/AppHistory/Subviews/AppHistorySampleView.swift → USB Meter/Views/Meter/Sheets/MeasurementSeries/Subviews/MeasurementSeriesSampleView.swift
@@ -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
+23 -12
USB Meter/Views/Meter/Tabs/Chart/MeterChartTabView.swift
@@ -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
 }
+2 -2
USB Meter/Views/Meter/Tabs/Home/MeterHomeTabView.swift
@@ -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
             }
+1 -1
USB Meter/Views/Meter/Tabs/Home/Subviews/MeterConnectionActionView.swift
@@ -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 {