Showing 2 changed files with 299 additions and 118 deletions
+219 -73
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -45,7 +45,6 @@ struct MeasurementChartView: View {
45 45
     private let minimumVoltageSpan = 0.5
46 46
     private let minimumCurrentSpan = 0.5
47 47
     private let minimumPowerSpan = 0.5
48
-    private let axisSwipeThreshold: CGFloat = 12
49 48
     private let defaultEmptyChartTimeSpan: TimeInterval = 60
50 49
 
51 50
     let compactLayout: Bool
@@ -64,6 +63,7 @@ struct MeasurementChartView: View {
64 63
     @State private var pinOrigin: Bool = false
65 64
     @State private var useSharedOrigin: Bool = false
66 65
     @State private var sharedAxisOrigin: Double = 0
66
+    @State private var sharedAxisUpperBound: Double = 1
67 67
     @State private var powerAxisOrigin: Double = 0
68 68
     @State private var voltageAxisOrigin: Double = 0
69 69
     @State private var currentAxisOrigin: Double = 0
@@ -81,7 +81,10 @@ struct MeasurementChartView: View {
81 81
     }
82 82
 
83 83
     private var axisColumnWidth: CGFloat {
84
-        compactLayout ? 38 : 46
84
+        if compactLayout {
85
+            return 38
86
+        }
87
+        return isLargeDisplay ? 62 : 46
85 88
     }
86 89
 
87 90
     private var chartSectionSpacing: CGFloat {
@@ -89,7 +92,10 @@ struct MeasurementChartView: View {
89 92
     }
90 93
 
91 94
     private var xAxisHeight: CGFloat {
92
-        compactLayout ? 24 : 28
95
+        if compactLayout {
96
+            return 24
97
+        }
98
+        return isLargeDisplay ? 36 : 28
93 99
     }
94 100
 
95 101
     private var plotSectionHeight: CGFloat {
@@ -116,6 +122,17 @@ struct MeasurementChartView: View {
116 122
         !compactLayout && !stackedToolbarLayout
117 123
     }
118 124
 
125
+    private var isLargeDisplay: Bool {
126
+        if availableSize.width > 0 {
127
+            return availableSize.width >= 900 || availableSize.height >= 700
128
+        }
129
+        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
130
+    }
131
+
132
+    private var chartBaseFont: Font {
133
+        isLargeDisplay ? .callout : .footnote
134
+    }
135
+
119 136
     var body: some View {
120 137
         let powerSeries = series(for: measurements.power, kind: .power, minimumYSpan: minimumPowerSpan)
121 138
         let voltageSeries = series(for: measurements.voltage, kind: .voltage, minimumYSpan: minimumVoltageSpan)
@@ -129,10 +146,7 @@ struct MeasurementChartView: View {
129 146
         Group {
130 147
             if let primarySeries {
131 148
                 VStack(alignment: .leading, spacing: 12) {
132
-                    chartToggleBar(
133
-                        voltageSeries: voltageSeries,
134
-                        currentSeries: currentSeries
135
-                    )
149
+                    chartToggleBar()
136 150
 
137 151
                     GeometryReader { geometry in
138 152
                         let plotHeight = max(geometry.size.height - xAxisHeight, compactLayout ? 180 : 220)
@@ -175,6 +189,13 @@ struct MeasurementChartView: View {
175 189
                                 )
176 190
                                 .frame(width: axisColumnWidth, height: plotHeight)
177 191
                             }
192
+                            .overlay(alignment: .bottom) {
193
+                                scaleControlsPill(
194
+                                    voltageSeries: voltageSeries,
195
+                                    currentSeries: currentSeries
196
+                                )
197
+                                .padding(.bottom, compactLayout ? 6 : 10)
198
+                            }
178 199
 
179 200
                             xAxisLabelsView(context: primarySeries.context)
180 201
                             .frame(height: xAxisHeight)
@@ -185,16 +206,13 @@ struct MeasurementChartView: View {
185 206
                 }
186 207
             } else {
187 208
                 VStack(alignment: .leading, spacing: 12) {
188
-                    chartToggleBar(
189
-                        voltageSeries: voltageSeries,
190
-                        currentSeries: currentSeries
191
-                    )
209
+                    chartToggleBar()
192 210
                     Text("Select at least one measurement series.")
193 211
                         .foregroundColor(.secondary)
194 212
                 }
195 213
             }
196 214
         }
197
-        .font(.footnote)
215
+        .font(chartBaseFont)
198 216
         .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
199 217
         .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
200 218
             guard timeRange == nil else { return }
@@ -202,37 +220,37 @@ struct MeasurementChartView: View {
202 220
         }
203 221
     }
204 222
 
205
-    private func chartToggleBar(
206
-        voltageSeries: SeriesData,
207
-        currentSeries: SeriesData
208
-    ) -> some View {
223
+    private func chartToggleBar() -> some View {
209 224
         let condensedLayout = compactLayout || verticalSizeClass == .compact
225
+        let sectionSpacing: CGFloat = condensedLayout ? 8 : (isLargeDisplay ? 12 : 10)
210 226
 
211
-        return VStack(alignment: .leading, spacing: condensedLayout ? 8 : 10) {
227
+        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
212 228
             seriesToggleRow(condensedLayout: condensedLayout)
229
+        }
230
+        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
231
+        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
232
+        .background(
233
+            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
234
+                .fill(Color.primary.opacity(0.045))
235
+        )
236
+        .overlay(
237
+            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
238
+                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
239
+        )
213 240
 
241
+        return Group {
214 242
             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)
243
+                VStack(alignment: .leading, spacing: condensedLayout ? 8 : 10) {
244
+                    controlsPanel
245
+                    HStack {
246
+                        Spacer(minLength: 0)
247
+                        resetBufferButton(condensedLayout: condensedLayout)
248
+                    }
225 249
                 }
226 250
             } else {
227
-                HStack(alignment: .center, spacing: 16) {
228
-                    originControlsRow(
229
-                        voltageSeries: voltageSeries,
230
-                        currentSeries: currentSeries,
231
-                        condensedLayout: condensedLayout
232
-                    )
233
-
251
+                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
252
+                    controlsPanel
234 253
                     Spacer(minLength: 0)
235
-
236 254
                     resetBufferButton(condensedLayout: condensedLayout)
237 255
                 }
238 256
             }
@@ -240,6 +258,44 @@ struct MeasurementChartView: View {
240 258
         .frame(maxWidth: .infinity, alignment: .leading)
241 259
     }
242 260
 
261
+    private var shouldFloatScaleControlsOverChart: Bool {
262
+        #if os(iOS)
263
+        if availableSize.width > 0, availableSize.height > 0 {
264
+            return availableSize.width > availableSize.height
265
+        }
266
+        return horizontalSizeClass != .compact && verticalSizeClass == .compact
267
+        #else
268
+        return false
269
+        #endif
270
+    }
271
+
272
+    private func scaleControlsPill(
273
+        voltageSeries: SeriesData,
274
+        currentSeries: SeriesData
275
+    ) -> some View {
276
+        let condensedLayout = compactLayout || verticalSizeClass == .compact
277
+
278
+        return originControlsRow(
279
+            voltageSeries: voltageSeries,
280
+            currentSeries: currentSeries,
281
+            condensedLayout: condensedLayout,
282
+            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
283
+        )
284
+        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
285
+        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
286
+        .background(
287
+            Capsule(style: .continuous)
288
+                .fill(shouldFloatScaleControlsOverChart ? Color.clear : Color.primary.opacity(0.08))
289
+        )
290
+        .overlay(
291
+            Capsule(style: .continuous)
292
+                .stroke(
293
+                    shouldFloatScaleControlsOverChart ? Color.clear : Color.secondary.opacity(0.18),
294
+                    lineWidth: 1
295
+                )
296
+        )
297
+    }
298
+
243 299
     private func seriesToggleRow(condensedLayout: Bool) -> some View {
244 300
         HStack(spacing: condensedLayout ? 6 : 8) {
245 301
             seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
@@ -269,7 +325,8 @@ struct MeasurementChartView: View {
269 325
     private func originControlsRow(
270 326
         voltageSeries: SeriesData,
271 327
         currentSeries: SeriesData,
272
-        condensedLayout: Bool
328
+        condensedLayout: Bool,
329
+        showsLabel: Bool
273 330
     ) -> some View {
274 331
         HStack(spacing: condensedLayout ? 8 : 10) {
275 332
             symbolControlChip(
@@ -277,9 +334,9 @@ struct MeasurementChartView: View {
277 334
                 enabled: supportsSharedOrigin,
278 335
                 active: useSharedOrigin && supportsSharedOrigin,
279 336
                 condensedLayout: condensedLayout,
280
-                showsLabel: showsLabeledOriginControls,
281
-                label: "Match Y Origin",
282
-                accessibilityLabel: "Match Y origin"
337
+                showsLabel: showsLabel,
338
+                label: "Match Y Scale",
339
+                accessibilityLabel: "Match Y scale"
283 340
             ) {
284 341
                 toggleSharedOrigin(
285 342
                     voltageSeries: voltageSeries,
@@ -292,7 +349,7 @@ struct MeasurementChartView: View {
292 349
                 enabled: true,
293 350
                 active: pinOrigin,
294 351
                 condensedLayout: condensedLayout,
295
-                showsLabel: showsLabeledOriginControls,
352
+                showsLabel: showsLabel,
296 353
                 label: pinOrigin ? "Origin Locked" : "Origin Auto",
297 354
                 accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
298 355
             ) {
@@ -307,12 +364,13 @@ struct MeasurementChartView: View {
307 364
                 enabled: true,
308 365
                 active: pinnedOriginIsZero,
309 366
                 condensedLayout: condensedLayout,
310
-                showsLabel: showsLabeledOriginControls,
367
+                showsLabel: showsLabel,
311 368
                 label: "Origin 0",
312 369
                 accessibilityLabel: "Set origin to zero"
313 370
             ) {
314 371
                 setVisibleOriginsToZero()
315 372
             }
373
+
316 374
         }
317 375
     }
318 376
 
@@ -324,13 +382,13 @@ struct MeasurementChartView: View {
324 382
     ) -> some View {
325 383
         Button(action: action) {
326 384
             Text(title)
327
-                .font((condensedLayout ? Font.callout : .body).weight(.semibold))
385
+                .font(seriesToggleFont(condensedLayout: condensedLayout))
328 386
                 .lineLimit(1)
329 387
                 .minimumScaleFactor(0.82)
330 388
                 .foregroundColor(isOn ? .white : .blue)
331
-                .padding(.horizontal, condensedLayout ? 10 : 12)
332
-                .padding(.vertical, condensedLayout ? 7 : 8)
333
-                .frame(minWidth: condensedLayout ? 0 : 84)
389
+                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
390
+                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
391
+                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
334 392
                 .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
335 393
                 .background(
336 394
                     RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
@@ -360,13 +418,16 @@ struct MeasurementChartView: View {
360 418
             Group {
361 419
                 if showsLabel {
362 420
                     Label(label, systemImage: systemImage)
363
-                        .font((condensedLayout ? Font.callout : .footnote).weight(.semibold))
421
+                        .font(controlChipFont(condensedLayout: condensedLayout))
364 422
                         .padding(.horizontal, condensedLayout ? 10 : 12)
365
-                        .padding(.vertical, condensedLayout ? 7 : 8)
423
+                        .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 9 : 8))
366 424
                 } else {
367 425
                     Image(systemName: systemImage)
368
-                        .font(.system(size: condensedLayout ? 15 : 16, weight: .semibold))
369
-                        .frame(width: condensedLayout ? 34 : 38, height: condensedLayout ? 34 : 38)
426
+                        .font(.system(size: condensedLayout ? 15 : (isLargeDisplay ? 18 : 16), weight: .semibold))
427
+                        .frame(
428
+                            width: condensedLayout ? 34 : (isLargeDisplay ? 42 : 38),
429
+                            height: condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)
430
+                        )
370 431
                 }
371 432
             }
372 433
                 .background(
@@ -385,9 +446,9 @@ struct MeasurementChartView: View {
385 446
             showResetConfirmation = true
386 447
         }) {
387 448
             Label(condensedLayout ? "Reset" : "Reset Buffer", systemImage: "trash")
388
-                .font((condensedLayout ? Font.callout : .footnote).weight(.semibold))
449
+                .font(controlChipFont(condensedLayout: condensedLayout))
389 450
                 .padding(.horizontal, condensedLayout ? 14 : 16)
390
-                .padding(.vertical, condensedLayout ? 10 : 11)
451
+                .padding(.vertical, condensedLayout ? 10 : (isLargeDisplay ? 12 : 11))
391 452
         }
392 453
         .buttonStyle(.plain)
393 454
         .foregroundColor(.white)
@@ -404,6 +465,20 @@ struct MeasurementChartView: View {
404 465
         }
405 466
     }
406 467
 
468
+    private func seriesToggleFont(condensedLayout: Bool) -> Font {
469
+        if isLargeDisplay {
470
+            return .body.weight(.semibold)
471
+        }
472
+        return (condensedLayout ? Font.callout : .body).weight(.semibold)
473
+    }
474
+
475
+    private func controlChipFont(condensedLayout: Bool) -> Font {
476
+        if isLargeDisplay {
477
+            return .callout.weight(.semibold)
478
+        }
479
+        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
480
+    }
481
+
407 482
     @ViewBuilder
408 483
     private func primaryAxisView(
409 484
         height: CGFloat,
@@ -552,6 +627,10 @@ struct MeasurementChartView: View {
552 627
         displayVoltage && displayCurrent && !displayPower
553 628
     }
554 629
 
630
+    private var minimumSharedScaleSpan: Double {
631
+        max(minimumVoltageSpan, minimumCurrentSpan)
632
+    }
633
+
555 634
     private var pinnedOriginIsZero: Bool {
556 635
         if useSharedOrigin && supportsSharedOrigin {
557 636
             return pinOrigin && sharedAxisOrigin == 0
@@ -587,6 +666,8 @@ struct MeasurementChartView: View {
587 666
             currentSeries: currentSeries
588 667
         )
589 668
         sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
669
+        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
670
+        ensureSharedScaleSpan()
590 671
         useSharedOrigin = true
591 672
         pinOrigin = true
592 673
     }
@@ -609,9 +690,12 @@ struct MeasurementChartView: View {
609 690
 
610 691
     private func setVisibleOriginsToZero() {
611 692
         if useSharedOrigin && supportsSharedOrigin {
693
+            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
612 694
             sharedAxisOrigin = 0
695
+            sharedAxisUpperBound = currentSpan
613 696
             voltageAxisOrigin = 0
614 697
             currentAxisOrigin = 0
698
+            ensureSharedScaleSpan()
615 699
         } else {
616 700
             if displayPower {
617 701
                 powerAxisOrigin = 0
@@ -635,6 +719,8 @@ struct MeasurementChartView: View {
635 719
         voltageAxisOrigin = voltageSeries.autoLowerBound
636 720
         currentAxisOrigin = currentSeries.autoLowerBound
637 721
         sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
722
+        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
723
+        ensureSharedScaleSpan()
638 724
     }
639 725
 
640 726
     private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
@@ -742,6 +828,10 @@ struct MeasurementChartView: View {
742 828
             return autoUpperBound
743 829
         }
744 830
 
831
+        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
832
+            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
833
+        }
834
+
745 835
         return max(
746 836
             maximumSampleValue ?? lowerBound,
747 837
             lowerBound + minimumYSpan,
@@ -749,15 +839,15 @@ struct MeasurementChartView: View {
749 839
         )
750 840
     }
751 841
 
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
842
+    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
756 843
         let baseline = displayedLowerBoundForSeries(kind)
757 844
         let proposedOrigin = snappedOriginValue(baseline + delta)
758 845
 
759 846
         if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
847
+            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
760 848
             sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
849
+            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
850
+            ensureSharedScaleSpan()
761 851
         } else {
762 852
             switch kind {
763 853
             case .power:
@@ -772,6 +862,41 @@ struct MeasurementChartView: View {
772 862
         pinOrigin = true
773 863
     }
774 864
 
865
+    private func clearOriginOffset(for kind: SeriesKind) {
866
+        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
867
+            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
868
+            sharedAxisOrigin = 0
869
+            sharedAxisUpperBound = currentSpan
870
+            ensureSharedScaleSpan()
871
+            voltageAxisOrigin = 0
872
+            currentAxisOrigin = 0
873
+        } else {
874
+            switch kind {
875
+            case .power:
876
+                powerAxisOrigin = 0
877
+            case .voltage:
878
+                voltageAxisOrigin = 0
879
+            case .current:
880
+                currentAxisOrigin = 0
881
+            }
882
+        }
883
+
884
+        pinOrigin = true
885
+    }
886
+
887
+    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
888
+        guard totalHeight > 1 else { return }
889
+
890
+        let normalized = max(0, min(1, locationY / totalHeight))
891
+        if normalized < (1.0 / 3.0) {
892
+            applyOriginDelta(-1, kind: kind)
893
+        } else if normalized < (2.0 / 3.0) {
894
+            clearOriginOffset(for: kind)
895
+        } else {
896
+            applyOriginDelta(1, kind: kind)
897
+        }
898
+    }
899
+
775 900
     private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
776 901
         switch kind {
777 902
         case .power:
@@ -790,6 +915,10 @@ struct MeasurementChartView: View {
790 915
         )
791 916
     }
792 917
 
918
+    private func ensureSharedScaleSpan() {
919
+        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
920
+    }
921
+
793 922
     private func snappedOriginValue(_ value: Double) -> Double {
794 923
         if value >= 0 {
795 924
             return value.rounded(.down)
@@ -865,10 +994,10 @@ struct MeasurementChartView: View {
865 994
                         )
866 995
 
867 996
                         Text(item.element)
868
-                            .font(.caption.weight(.semibold))
997
+                            .font((isLargeDisplay ? Font.callout : .caption).weight(.semibold))
869 998
                             .monospacedDigit()
870 999
                             .lineLimit(1)
871
-                            .minimumScaleFactor(0.68)
1000
+                            .minimumScaleFactor(0.74)
872 1001
                             .frame(width: labelWidth)
873 1002
                             .position(
874 1003
                                 x: centerX,
@@ -892,42 +1021,59 @@ struct MeasurementChartView: View {
892 1021
         tint: Color
893 1022
     ) -> some View {
894 1023
         GeometryReader { geometry in
1024
+            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1025
+            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1026
+            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1027
+
895 1028
             ZStack(alignment: .top) {
896 1029
                 ForEach(0..<yLabels, id: \.self) { row in
897 1030
                     let labelIndex = yLabels - row
898 1031
 
899 1032
                     Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
900
-                        .font(.caption2.weight(.semibold))
1033
+                        .font((isLargeDisplay ? Font.callout : .footnote).weight(.semibold))
901 1034
                         .monospacedDigit()
902 1035
                         .lineLimit(1)
903
-                        .minimumScaleFactor(0.72)
904
-                        .frame(width: max(geometry.size.width - 6, 0))
1036
+                        .minimumScaleFactor(0.8)
1037
+                        .frame(width: max(geometry.size.width - 10, 0))
905 1038
                         .position(
906 1039
                             x: geometry.size.width / 2,
907
-                            y: yGuidePosition(
1040
+                            y: topInset + yGuidePosition(
908 1041
                                 for: labelIndex,
909 1042
                                 context: context,
910
-                                height: geometry.size.height
1043
+                                height: labelAreaHeight
911 1044
                             )
912 1045
                         )
913 1046
                 }
914 1047
 
915 1048
                 Text(measurementUnit)
916
-                    .font(.caption2.weight(.bold))
1049
+                    .font((isLargeDisplay ? Font.footnote : .caption2).weight(.bold))
917 1050
                     .foregroundColor(tint)
918
-                    .padding(.horizontal, 6)
919
-                    .padding(.vertical, 4)
1051
+                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1052
+                    .padding(.vertical, isLargeDisplay ? 5 : 4)
920 1053
                     .background(
921 1054
                         Capsule(style: .continuous)
922 1055
                             .fill(tint.opacity(0.14))
923 1056
                     )
924
-                    .padding(.top, 6)
1057
+                    .padding(.top, 8)
1058
+
1059
+                VStack {
1060
+                    Spacer(minLength: 0)
925 1061
 
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)
1062
+                    HStack(spacing: 6) {
1063
+                        Text("Y \(Int(displayedLowerBoundForSeries(seriesKind)))")
1064
+                            .font((isLargeDisplay ? Font.callout : .caption2).weight(.semibold))
1065
+                            .foregroundColor(.secondary)
1066
+                        Spacer(minLength: 0)
1067
+                    }
1068
+                    .padding(.horizontal, 6)
1069
+                    .padding(.vertical, 6)
1070
+                    .background(
1071
+                        Capsule(style: .continuous)
1072
+                            .fill(Color.primary.opacity(0.06))
1073
+                    )
1074
+                    .padding(.horizontal, 4)
1075
+                    .padding(.bottom, 4)
1076
+                }
931 1077
             }
932 1078
         }
933 1079
         .frame(height: height)
@@ -941,9 +1087,9 @@ struct MeasurementChartView: View {
941 1087
         )
942 1088
         .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
943 1089
         .gesture(
944
-            DragGesture(minimumDistance: axisSwipeThreshold)
1090
+            DragGesture(minimumDistance: 0)
945 1091
                 .onEnded { value in
946
-                    adjustOrigin(for: seriesKind, translationHeight: value.translation.height)
1092
+                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
947 1093
                 }
948 1094
         )
949 1095
     }
+80 -45
USB Meter/Views/Meter/Tabs/Live/Subviews/MeterLiveContentView.swift
@@ -30,59 +30,70 @@ struct MeterLiveContentView: View {
30 30
                 MeterInfoRowView(label: "Last Seen", value: meterHistoryText(for: meter.lastSeen))
31 31
                 MeterInfoRowView(label: "Last Connected", value: meterHistoryText(for: meter.lastConnectedAt))
32 32
             }
33
+            .frame(maxWidth: .infinity, alignment: .leading)
33 34
 
34 35
             LazyVGrid(columns: liveMetricColumns, spacing: compactLayout ? 10 : 12) {
35
-                liveMetricCard(
36
-                    title: "Voltage",
37
-                    symbol: "bolt.fill",
38
-                    color: .green,
39
-                    value: "\(meter.voltage.format(decimalDigits: 3)) V",
40
-                    range: metricRange(
41
-                        min: meter.measurements.voltage.context.minValue,
42
-                        max: meter.measurements.voltage.context.maxValue,
43
-                        unit: "V"
36
+                if shouldShowVoltageCard {
37
+                    liveMetricCard(
38
+                        title: "Voltage",
39
+                        symbol: "bolt.fill",
40
+                        color: .green,
41
+                        value: "\(meter.voltage.format(decimalDigits: 3)) V",
42
+                        range: metricRange(
43
+                            min: meter.measurements.voltage.context.minValue,
44
+                            max: meter.measurements.voltage.context.maxValue,
45
+                            unit: "V"
46
+                        )
44 47
                     )
45
-                )
48
+                }
46 49
 
47
-                liveMetricCard(
48
-                    title: "Current",
49
-                    symbol: "waveform.path.ecg",
50
-                    color: .blue,
51
-                    value: "\(meter.current.format(decimalDigits: 3)) A",
52
-                    range: metricRange(
53
-                        min: meter.measurements.current.context.minValue,
54
-                        max: meter.measurements.current.context.maxValue,
55
-                        unit: "A"
50
+                if shouldShowCurrentCard {
51
+                    liveMetricCard(
52
+                        title: "Current",
53
+                        symbol: "waveform.path.ecg",
54
+                        color: .blue,
55
+                        value: "\(meter.current.format(decimalDigits: 3)) A",
56
+                        range: metricRange(
57
+                            min: meter.measurements.current.context.minValue,
58
+                            max: meter.measurements.current.context.maxValue,
59
+                            unit: "A"
60
+                        )
56 61
                     )
57
-                )
62
+                }
58 63
 
59
-                liveMetricCard(
60
-                    title: "Power",
61
-                    symbol: "flame.fill",
62
-                    color: .pink,
63
-                    value: "\(meter.power.format(decimalDigits: 3)) W",
64
-                    range: metricRange(
65
-                        min: meter.measurements.power.context.minValue,
66
-                        max: meter.measurements.power.context.maxValue,
67
-                        unit: "W"
64
+                if shouldShowPowerCard {
65
+                    liveMetricCard(
66
+                        title: "Power",
67
+                        symbol: "flame.fill",
68
+                        color: .pink,
69
+                        value: "\(meter.power.format(decimalDigits: 3)) W",
70
+                        range: metricRange(
71
+                            min: meter.measurements.power.context.minValue,
72
+                            max: meter.measurements.power.context.maxValue,
73
+                            unit: "W"
74
+                        )
68 75
                     )
69
-                )
76
+                }
70 77
 
71
-                liveMetricCard(
72
-                    title: "Temperature",
73
-                    symbol: "thermometer.medium",
74
-                    color: .orange,
75
-                    value: meter.primaryTemperatureDescription,
76
-                    range: temperatureRange()
77
-                )
78
+                if shouldShowTemperatureCard {
79
+                    liveMetricCard(
80
+                        title: "Temperature",
81
+                        symbol: "thermometer.medium",
82
+                        color: .orange,
83
+                        value: meter.primaryTemperatureDescription,
84
+                        range: temperatureRange()
85
+                    )
86
+                }
78 87
 
79
-                liveMetricCard(
80
-                    title: "Load",
81
-                    customSymbol: AnyView(LoadResistanceIconView(color: .yellow)),
82
-                    color: .yellow,
83
-                    value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
84
-                    detailText: "Measured resistance"
85
-                )
88
+                if shouldShowLoadCard {
89
+                    liveMetricCard(
90
+                        title: "Load",
91
+                        customSymbol: AnyView(LoadResistanceIconView(color: .yellow)),
92
+                        color: .yellow,
93
+                        value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
94
+                        detailText: "Measured resistance"
95
+                    )
96
+                }
86 97
 
87 98
                 liveMetricCard(
88 99
                     title: "RSSI",
@@ -98,7 +109,7 @@ struct MeterLiveContentView: View {
98 109
                     valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold)
99 110
                 )
100 111
 
101
-                if meter.supportsChargerDetection {
112
+                if meter.supportsChargerDetection && hasLiveMetrics {
102 113
                     liveMetricCard(
103 114
                         title: "Detected Charger",
104 115
                         symbol: "powerplug.fill",
@@ -116,6 +127,30 @@ struct MeterLiveContentView: View {
116 127
         .frame(maxWidth: .infinity, alignment: .topLeading)
117 128
     }
118 129
 
130
+    private var hasLiveMetrics: Bool {
131
+        meter.operationalState == .dataIsAvailable
132
+    }
133
+
134
+    private var shouldShowVoltageCard: Bool {
135
+        hasLiveMetrics && meter.measurements.voltage.context.isValid && meter.voltage.isFinite
136
+    }
137
+
138
+    private var shouldShowCurrentCard: Bool {
139
+        hasLiveMetrics && meter.measurements.current.context.isValid && meter.current.isFinite
140
+    }
141
+
142
+    private var shouldShowPowerCard: Bool {
143
+        hasLiveMetrics && meter.measurements.power.context.isValid && meter.power.isFinite
144
+    }
145
+
146
+    private var shouldShowTemperatureCard: Bool {
147
+        hasLiveMetrics && meter.displayedTemperatureValue.isFinite
148
+    }
149
+
150
+    private var shouldShowLoadCard: Bool {
151
+        hasLiveMetrics && meter.loadResistance.isFinite && meter.loadResistance > 0
152
+    }
153
+
119 154
     private var liveMetricColumns: [GridItem] {
120 155
         if compactLayout {
121 156
             return Array(repeating: GridItem(.flexible(), spacing: 10), count: 3)