@@ -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 |
} |
@@ -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) |